diff --git a/.eslintrc.json b/.eslintrc.json new file mode 100644 index 0000000..3722418 --- /dev/null +++ b/.eslintrc.json @@ -0,0 +1,3 @@ +{ + "extends": ["next/core-web-vitals", "next/typescript"] +} diff --git a/.github/workflows/nextjs.yml b/.github/workflows/nextjs.yml new file mode 100644 index 0000000..ed74736 --- /dev/null +++ b/.github/workflows/nextjs.yml @@ -0,0 +1,93 @@ +# Sample workflow for building and deploying a Next.js site to GitHub Pages +# +# To get started with Next.js see: https://nextjs.org/docs/getting-started +# +name: Deploy Next.js site to Pages + +on: + # Runs on pushes targeting the default branch + push: + branches: ["main"] + + # Allows you to run this workflow manually from the Actions tab + workflow_dispatch: + +# Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages +permissions: + contents: read + pages: write + id-token: write + +# Allow only one concurrent deployment, skipping runs queued between the run in-progress and latest queued. +# However, do NOT cancel in-progress runs as we want to allow these production deployments to complete. +concurrency: + group: "pages" + cancel-in-progress: false + +jobs: + # Build job + build: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + - name: Detect package manager + id: detect-package-manager + run: | + if [ -f "${{ github.workspace }}/yarn.lock" ]; then + echo "manager=yarn" >> $GITHUB_OUTPUT + echo "command=install" >> $GITHUB_OUTPUT + echo "runner=yarn" >> $GITHUB_OUTPUT + exit 0 + elif [ -f "${{ github.workspace }}/package.json" ]; then + echo "manager=npm" >> $GITHUB_OUTPUT + echo "command=ci" >> $GITHUB_OUTPUT + echo "runner=npx --no-install" >> $GITHUB_OUTPUT + exit 0 + else + echo "Unable to determine package manager" + exit 1 + fi + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: "20" + cache: ${{ steps.detect-package-manager.outputs.manager }} + - name: Setup Pages + uses: actions/configure-pages@v5 + with: + # Automatically inject basePath in your Next.js configuration file and disable + # server side image optimization (https://nextjs.org/docs/api-reference/next/image#unoptimized). + # + # You may remove this line if you want to manage the configuration yourself. + static_site_generator: next + - name: Restore cache + uses: actions/cache@v4 + with: + path: | + .next/cache + # Generate a new cache whenever packages or source files change. + key: ${{ runner.os }}-nextjs-${{ hashFiles('**/package-lock.json', '**/yarn.lock') }}-${{ hashFiles('**.[jt]s', '**.[jt]sx') }} + # If source files changed but packages didn't, rebuild from a prior cache. + restore-keys: | + ${{ runner.os }}-nextjs-${{ hashFiles('**/package-lock.json', '**/yarn.lock') }}- + - name: Install dependencies + run: ${{ steps.detect-package-manager.outputs.manager }} ${{ steps.detect-package-manager.outputs.command }} + - name: Build with Next.js + run: ${{ steps.detect-package-manager.outputs.runner }} next build + - name: Upload artifact + uses: actions/upload-pages-artifact@v3 + with: + path: ./out + + # Deployment job + deploy: + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + runs-on: ubuntu-latest + needs: build + steps: + - name: Deploy to GitHub Pages + id: deployment + uses: actions/deploy-pages@v4 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..fd3dbb5 --- /dev/null +++ b/.gitignore @@ -0,0 +1,36 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.js +.yarn/install-state.gz + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# production +/build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# local env files +.env*.local + +# vercel +.vercel + +# typescript +*.tsbuildinfo +next-env.d.ts diff --git a/README.md b/README.md new file mode 100644 index 0000000..27a9c09 --- /dev/null +++ b/README.md @@ -0,0 +1,172 @@ + +[] Priority 1: Basic functionality : creating as profile and viewing it + +[] allow editing the profile. + +[] post publishing redirect to the profile page + + + + +[] P2: Make it performant! + [] use localstorage to keep data if the page is restarted + +[] P3: polish! + + [] allow searching with coin name and network name like BTC and Bitcoin + [] Progress Indicator: Implement a progress bar or step indicator to show users how many steps are left and their current position. + + [] Responsiveness: Ensure the layout adapts well to different screen sizes and devices. This maintains usability and readability across platforms. + + [] add switch between light and dark mode + https://nextui.org/docs/customization/theme + https://nextui.org/docs/customization/dark-mode + + [] allow accessibility (without using the mouse) + +Inline Help: Provide contextual help or tooltips next to fields to guide users on what information is needed. + +Help Section: Include a dedicated help section or FAQ for more detailed queries. + + + +use must first sign a message? +or maybe not, because eventually they have to send a tx + + +[] sample: + public: + https://linktr.ee/selenagomez + https://coinsend.to/@billionaire.bill + + +[] data to enter: + Item { + url, + title, + image + } + +[] Share the profile on multiple platforms + +[] report violation of the link + +[] where to upload images? allow them to put image url? + +[] make the visited links obvious + +[] do not make the page soo long on height + +[] allow customization by pull requests on the repo! + + + +[] list on wallet connect: + Grow your audience on our Explorer + Reach thousands of daily users and improve your project's discovery by listing it on our Explorer + + +[] Add Sepolia to your metamask: + https://chainid.link/?network=op-sepolia + + +[] Linktrue deployed at testnet: + 0x8faC1b937a41cE91E51569451afBFbD5998c1CEC + + +OP Sepolia Testnet +LinkTrue +Balance +0.04998 ETH +https://cloud.walletconnect.com +Sign-in request +This site is requesting to sign in with +LinkTrue +Message: +Please sign with your account + +URI: +https://cloud.walletconnect.com + +Version: +1 + +Chain ID: +11155420 + +Nonce: +GAaTlaC3ZFBd9CvL0 + +Issued At: +2024-09-13T12:54:20.277Z + +Expires At: +2024-09-20T12:54:20.269Z + +Cancel +Sign-In + + + + + + + + + + + + + + + + + + + +This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app). + + + + + + + + + + + +## Getting Started + +First, run the development server: + +```bash +npm run dev +# or +yarn dev +# or +pnpm dev +# or +bun dev +``` + +Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. + +You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. + +This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel. + +## Learn More + +To learn more about Next.js, take a look at the following resources: + +- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. +- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. + +You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome! + +## Deploy on Vercel + +The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. + +Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details. diff --git a/app/[username]/page.tsx b/app/[username]/page.tsx new file mode 100644 index 0000000..dca4bfb --- /dev/null +++ b/app/[username]/page.tsx @@ -0,0 +1,112 @@ +"use client" +import { useEffect, useState } from 'react'; +import Preview from '@/components/Preview'; +import { useParams } from 'next/navigation'; +import { useBlockchain } from "@/context/BlockchainProvider"; +import { useContractMethods } from '@/hooks/useContractMethods'; +import { useGlobalState, Web2Item, Web3Item } from '@/context/GlobalStateContext'; + + +export default function UserProfile() { + const params = useParams(); + const { + handleConnectWallet, + isConnecting, + isConnected, + } = useBlockchain(); + + const { + setUserProfile, + + } = useGlobalState(); + + const { getProfileByUsername } = useContractMethods(); + + const [username, setUsername] = useState("") + const [isFetching, setIsFetching] = useState(false) + + + // Ensure that username is treated as a string and decode it from URL encoding + useEffect(() => { + const rawUsername = Array.isArray(params.username) ? params.username[0] : params.username; + const cleanUsername = decodeURIComponent(rawUsername).startsWith('@') ? rawUsername.slice(3) : rawUsername; + setUsername(cleanUsername); + }, [params.username]); + + useEffect(() => { + if (isConnecting) return; + if (!isConnected) { + handleConnectWallet(false); + } + }, [isConnected, isConnecting, handleConnectWallet]); + + useEffect(() => { + if (isConnected && username.length > 0 && !isFetching) { + setIsFetching(true); + getProfileByUsername(username).then(res => { + const cleanedData = parseResult(res); + setUserProfile( + { + avatar: '',//todo what to do? + username: username, + web2Items: cleanedData.web2Items, + web3Items: cleanedData.web3Items + } + ) + }) + .catch(err => { + debugger + console.log(err); + }).finally(() => { + setIsFetching(false); + }) + } + }, [isConnected, username, getProfileByUsername]); + + function parseResult(result: string[]) { + // Initialize arrays for Web2 and Web3 items + let web2Items: Web2Item[] = []; + let web3Items: Web3Item[] = []; + + // Loop through the result and process each item + for (let i = 0; i < result.length; i++) { + // Determine if the item is a Web2 item (icon URL and full URL) + if (i % 2 === 0) { + // Check if the next item exists and is a valid URL + if (i + 1 < result.length && /^https?:\/\//.test(result[i + 1])) { + web2Items.push({ + iconUrl: result[i], + fullURL: result[i + 1] + }); + } + } + // Determine if the item is a Web3 item (icon URL and wallet address) + else { + // Check if the current item is a valid wallet address + if (/^0x[a-fA-F0-9]{40}$/.test(result[i])) { + web3Items.push({ + icon: result[i - 1], + walletAddress: result[i] + }); + } + } + } + + // Return the organized object + return { + web2Items, + web3Items + }; + } + + // fetch the profile data using a public rpc node + // set the profile data into the global state + return ( +
+ {isConnecting ? <>Connecting ... : ''} + {isConnected && isFetching ? <>Reading Blockchain Data ... : ''} + {isConnected && !isFetching ? : ''} + +
+ ); +} diff --git a/app/favicon.ico b/app/favicon.ico new file mode 100644 index 0000000..718d6fe Binary files /dev/null and b/app/favicon.ico differ diff --git a/app/fonts/GeistMonoVF.woff b/app/fonts/GeistMonoVF.woff new file mode 100644 index 0000000..f2ae185 Binary files /dev/null and b/app/fonts/GeistMonoVF.woff differ diff --git a/app/fonts/GeistVF.woff b/app/fonts/GeistVF.woff new file mode 100644 index 0000000..1b62daa Binary files /dev/null and b/app/fonts/GeistVF.woff differ diff --git a/app/globals.css b/app/globals.css new file mode 100644 index 0000000..c0f064e --- /dev/null +++ b/app/globals.css @@ -0,0 +1,45 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +:root { + --background: #ffffff; + --foreground: #171717; +} + +/* @media (prefers-color-scheme: dark) { + :root { + --background: #0a0a0a; + --foreground: #ededed; + } +} */ + +body { + color: var(--foreground); + background: var(--background); + font-family: Arial, Helvetica, sans-serif; +} + +@layer utilities { + .text-balance { + text-wrap: balance; + } +} + +.spinner { + border: 3px solid rgb(0, 0, 0); /* Light grey background */ + border-radius: 50%; + border-top: 3px solid rgb(255, 255, 255); /* Red spinner color */ + width: 16px; + height: 16px; + animation: spin 0.9s linear infinite; +} + +@keyframes spin { + 0% { + transform: rotate(0deg); + } + 100% { + transform: rotate(360deg); + } +} \ No newline at end of file diff --git a/app/layout.tsx b/app/layout.tsx new file mode 100644 index 0000000..6c4aed7 --- /dev/null +++ b/app/layout.tsx @@ -0,0 +1,77 @@ +import type { Metadata } from "next"; +import Image from "next/image"; +import localFont from "next/font/local"; +import "./globals.css"; +import '@fortawesome/fontawesome-free/css/all.min.css'; +import { Toaster as SimpleToast } from "@/components/ui/toaster" +import { Toaster } from "@/components/ui/sonner" + +import { StepsProvider } from "@/context/StepsContext"; +import { GlobalStateProvider } from "@/context/GlobalStateContext"; +import { SmartContractProvider } from '@/context/SmartContractContext'; +import { BlockchainProvider } from '@/context/BlockchainProvider'; +import { LoggerProvider } from '@/context/LoggerContext'; + +const geistSans = localFont({ + src: "./fonts/GeistVF.woff", + variable: "--font-geist-sans", + weight: "100 900", +}); +const geistMono = localFont({ + src: "./fonts/GeistMonoVF.woff", + variable: "--font-geist-mono", + weight: "100 900", +}); + +export const metadata: Metadata = { + title: 'LinkTrue', + description: 'Your uncensorable crypto profile' +}; + +export default function RootLayout({ + children, +}: Readonly<{ + children: React.ReactNode; +}>) { + return ( + + + + + + + + +
+ + Next.js logo + + {children} +
+
+
+
+
+ + +
+ + + ); +} diff --git a/app/page.tsx b/app/page.tsx new file mode 100644 index 0000000..6067899 --- /dev/null +++ b/app/page.tsx @@ -0,0 +1,56 @@ +"use client" +import Image from "next/image"; +import { useSteps, Steps } from '../context/StepsContext'; + +import Step1 from '../components/steps/S1Main'; +import Step2 from '../components/steps/S2Username'; +import Step3 from '../components/steps/S3Web2Items'; +import Step4 from '../components/steps/S4Web3Items'; +import Step5 from '../components/steps/S5Preview'; + +export default function Home() { + const { currentStep } = useSteps(); + + return ( +
+
+ + {/*

{currentStep}

*/} + + {currentStep === Steps.Main && ( + + )} + + {currentStep === Steps.Username && ( + + )} + {currentStep === Steps.Web2Addresses && ( + + )} + + {currentStep === Steps.Web3Addresses && ( + + )} + + {currentStep === Steps.Preview && + + } + +
+
+ +
+ ); +} diff --git a/components.json b/components.json new file mode 100644 index 0000000..481633d --- /dev/null +++ b/components.json @@ -0,0 +1,20 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "default", + "rsc": true, + "tsx": true, + "tailwind": { + "config": "tailwind.config.ts", + "css": "app/globals.css", + "baseColor": "neutral", + "cssVariables": true, + "prefix": "" + }, + "aliases": { + "components": "@/components", + "utils": "@/lib/utils", + "ui": "@/components/ui", + "lib": "@/lib", + "hooks": "@/hooks" + } +} \ No newline at end of file diff --git a/components/ChainsComboBox.jsx b/components/ChainsComboBox.jsx new file mode 100644 index 0000000..56dc15c --- /dev/null +++ b/components/ChainsComboBox.jsx @@ -0,0 +1,66 @@ +import { useState, useEffect, useImperativeHandle, forwardRef } from "react"; +import Select from "react-select"; + +const ChainsComboBox = forwardRef(({ onSelect }, ref) => { + const [icons, setIcons] = useState([]); + const [search, setSearch] = useState(""); + const [selectedOption, setSelectedOption] = useState(null); + + useEffect(() => { + const importAllIcons = () => { + const iconsContext = require.context("/public/icons/chains", false, /\.svg$/); + const iconFiles = iconsContext.keys().map((file) => { + const fileName = file.replace("./", ""); + return { + label: fileName.replace(".svg", ""), + value: `/icons/chains/${fileName}`, + }; + }); + setIcons(iconFiles); + }; + + importAllIcons(); + }, []); + + const handleChange = (option) => { + setSelectedOption(option); + if (onSelect) { + onSelect(option); + } + }; + + const clearSelection = () => { + setSelectedOption(null); + }; + + useImperativeHandle(ref, () => ({ + clearSelection, + })); + + return ( +
+ + icon.label.toLowerCase().includes(search.toLowerCase()) + )} + onInputChange={(inputValue) => setSearch(inputValue)} + onChange={handleChange} + getOptionLabel={(option) => ( +
+ {option.label} + {option.label} +
+ )} + placeholder="Search social media icons..." + isClearable + autoFocus={autoFocus} + /> +
+ ); +}); + +export default SocialMediaComboBox; diff --git a/components/Web3ItemsComboBox.jsx b/components/Web3ItemsComboBox.jsx new file mode 100644 index 0000000..1d569fa --- /dev/null +++ b/components/Web3ItemsComboBox.jsx @@ -0,0 +1,76 @@ +import { useState, useEffect, useImperativeHandle, forwardRef } from "react"; +import Select from "react-select"; +import { useGlobalState, Web3Item } from "@/context/GlobalStateContext"; + +const Web3ItemsComboBox = forwardRef(({ onSelect }, ref) => { + const [icons, setIcons] = useState([]); + const [search, setSearch] = useState(""); + const [selectedOption, setSelectedOption] = useState(null); + + const { + userProfile, + } = useGlobalState(); + + useEffect(() => { + // Load icons if userProfile.web3Items exists + if (userProfile && userProfile.web3Items) { + const userWeb3Items = userProfile.web3Items.map((item) => ({ + label: item.icon.split('/').pop().replace('.svg', ''), + value: item.icon, + })); + setIcons(userWeb3Items); + } + }, [userProfile]); + + const handleChange = (option) => { + setSelectedOption(option); + if (onSelect) { + const filteredItem = userProfile.web3Items.filter(i => i.icon === option?.value); + if (filteredItem[0]?.walletAddress?.length > 0) { + onSelect(filteredItem[0]); + } else { + onSelect({ + walletAddress: '', + title: '' + }); + } + } + }; + + const clearSelection = () => { + setSelectedOption(null); + }; + + useImperativeHandle(ref, () => ({ + clearSelection, + })); + + const options = icons.filter((icon) => + icon.label.toLowerCase().includes(search.toLowerCase()) + ); + + return ( +
+ + {usernameError && +

{usernameError}

+ } +
+ +
+ + + {userNameOK && !userNameCheckOK ? + + : userNameCheckOK ? + + : <> + } +
+ + ); +}; + +export default S2Username; \ No newline at end of file diff --git a/components/steps/S3Web2Items.tsx b/components/steps/S3Web2Items.tsx new file mode 100644 index 0000000..80dffec --- /dev/null +++ b/components/steps/S3Web2Items.tsx @@ -0,0 +1,164 @@ +import { RefObject, useRef, useState } from "react"; +import { ellipsify } from '@/lib/utils'; +import { useSteps } from '@/context/StepsContext' +import SocialMediaComboBox from '@/components/SocialMediaComboBox'; +import { useGlobalState, Web2Item } from "@/context/GlobalStateContext"; + +// Define the type for the ref +interface SocialMediaComboBoxHandle { + clearSelection: () => void; +} + +const S3Web2Items = () => { + const { prevStep, nextStep } = useSteps(); + const { + userProfile, + addWeb2Item, + removeWeb2Item + } = useGlobalState(); + + const [web2Item, setWeb2Item] = useState({ + iconUrl: '', + fullURL: '' + }); + + const comboBoxRef: RefObject = useRef(null); + + const handleClearSelection = () => { + if (comboBoxRef.current) { + comboBoxRef.current.clearSelection(); + } + }; + + const handleWeb2AddressesChange = (e: any) => { + const { name, value } = e.target; + setWeb2Item((prevItem) => ({ + ...prevItem, + [name]: value, + })); + }; + + const handleSelect = (selectedOption: any) => { + setWeb2Item({ + ...web2Item, + iconUrl: selectedOption ? selectedOption.value : "", // Update form with selected icon URL + }); + }; + + const handleAddItem = () => { + if (web2Item.iconUrl && web2Item.fullURL) { + addWeb2Item(web2Item); // Add item to the global state + setWeb2Item({ + iconUrl: '', fullURL: '' + }); // Clear the form + handleClearSelection(); + } else { + alert('Please fill out all fields'); + } + }; + + const handleRemoveItem = (index: number) => { + removeWeb2Item(index); + }; + + return ( +
+

Add your social media links

+ + + + +
+ + + +

+
+

+ + Summary: +
+ {userProfile.web2Items.length > 0 ? ( + + + + + + + + + + {userProfile.web2Items.map((item, index) => ( + + + + + + ))} + +
IconURLActions
+ {item.iconUrl} + + + + + + + + +
+ ) : ( +

No items added yet.

+ )} + +
+ +
+
+ + + +
+
+ ); +}; + +export default S3Web2Items; \ No newline at end of file diff --git a/components/steps/S4Web3Items.tsx b/components/steps/S4Web3Items.tsx new file mode 100644 index 0000000..61a725a --- /dev/null +++ b/components/steps/S4Web3Items.tsx @@ -0,0 +1,151 @@ +import { RefObject, useRef, useState } from "react"; +import { ellipsify } from '@/lib/utils'; +import { useGlobalState, Web3Item } from "@/context/GlobalStateContext"; +import { useSteps } from '@/context/StepsContext' +import ChainsComboBox from '@/components/ChainsComboBox'; +import EllipsifiedWalletAddress from '@/components/EllipsifiedAddress'; + + +// Define the type for the ref +interface ChainsComboBoxHandle { + clearSelection: () => void; +} + +const S4Web3Items = () => { + const { prevStep, nextStep } = useSteps(); + const { + userProfile, + addWeb3Item, + removeWeb3Item + } = useGlobalState(); + + const [web3Item, setWeb3Item] = useState({ + icon: '', + walletAddress: '', + }); + + const comboBoxRef: RefObject = useRef(null); + + const handleClearSelection = () => { + if (comboBoxRef.current) { + comboBoxRef.current.clearSelection(); + } + }; + + const handleWeb3ItemInputChange = (e: any) => { + const { name, value } = e.target; + setWeb3Item((prevItem) => ({ + ...prevItem, + [name]: value, + })); + }; + + const handleSelectChain = (selectedOption: any) => { + setWeb3Item({ + ...web3Item, + icon: selectedOption ? selectedOption.value : "", // Update form with selected icon URL + }); + }; + + const handleAddItem = () => { + if (web3Item.icon && web3Item.walletAddress) { + addWeb3Item( + { + icon: web3Item.icon, + walletAddress: web3Item.walletAddress, + }, + ); + setWeb3Item({ icon: '', walletAddress: '' }); // Clear the form + handleClearSelection(); + } else { + alert('Please fill out all fields'); + } + }; + + return ( +
+

Add your Crypto Wallet addresses

+ + +
+ + + +

+
+

+ +
+ {userProfile.web3Items.length > 0 ? ( + + + + + + + + + + {userProfile.web3Items.map((item, index) => ( + + + + + + ))} + +
IconAddressActions
+ {item.icon} + + + + +
+ ) : ( +

No items added yet.

+ )} + +
+ +
+
+ + + +
+
+ ); +}; + +export default S4Web3Items; \ No newline at end of file diff --git a/components/steps/S5Preview.tsx b/components/steps/S5Preview.tsx new file mode 100644 index 0000000..43a5a53 --- /dev/null +++ b/components/steps/S5Preview.tsx @@ -0,0 +1,99 @@ +import { useSteps } from '@/context/StepsContext'; +import Preview from '@/components/Preview'; +import { useContractMethods } from '@/hooks/useContractMethods'; +import { useGlobalState } from '@/context/GlobalStateContext'; +import { useState } from 'react'; + +const S5Preview = () => { + const { prevStep } = useSteps(); + const { publish } = useContractMethods(); + const { userProfile } = useGlobalState(); + const [isPublishing, setIsPublishing] = useState(false) + + const handlePublish = async () => { + + try { + // Create keys and filter out empty strings + const keys: string[] = [ + ...userProfile.web2Items.map(i => i.iconUrl).filter(item => item.length > 0), + ...userProfile.web3Items.map(i => i.icon).filter(item => item.length > 0) + ]; + + // Create values and filter out empty strings + const values: string[] = [ + ...userProfile.web2Items.map(i => i.fullURL).filter(item => item.length > 0), + ...userProfile.web3Items.map(i => i.walletAddress).filter(item => item.length > 0) + ]; + + // Ensure both arrays have matching lengths + if (keys.length !== values.length) { + throw new Error('Mismatched keys and values'); + } + + setIsPublishing(true); + publish(userProfile.username, keys, values).then((result) => { + console.log(result); + + debugger + //TODO redirect to the profile page. + + }).catch((err) => { + + console.log(err); + debugger + + }).finally(() => { + setIsPublishing(false) + }) + } catch (error) { + console.error('Publish failed', error); + } + }; + + return ( +
+ +

Preview

+

Here is how your profile will look like

+
+ + + +
+

All good?

+
+
+

+ then publish now to reserve it forever! +

+ +
+ + + +
+
+ ); +}; + +export default S5Preview; diff --git a/components/steps/S6Publish.tsx b/components/steps/S6Publish.tsx new file mode 100644 index 0000000..fb41f34 --- /dev/null +++ b/components/steps/S6Publish.tsx @@ -0,0 +1,12 @@ + +const S6Publish = () => { + + + return ( +
+

S6Publish

+
+ ); +}; + +export default S6Publish; \ No newline at end of file diff --git a/components/steps/S7Modification.tsx b/components/steps/S7Modification.tsx new file mode 100644 index 0000000..69c469d --- /dev/null +++ b/components/steps/S7Modification.tsx @@ -0,0 +1,12 @@ + +const S7Modification = () => { + + + return ( +
+

S7Modification

+
+ ); +}; + +export default S7Modification; \ No newline at end of file diff --git a/components/ui/button.tsx b/components/ui/button.tsx new file mode 100644 index 0000000..0ba4277 --- /dev/null +++ b/components/ui/button.tsx @@ -0,0 +1,56 @@ +import * as React from "react" +import { Slot } from "@radix-ui/react-slot" +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "@/lib/utils" + +const buttonVariants = cva( + "inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50", + { + variants: { + variant: { + default: "bg-primary text-primary-foreground hover:bg-primary/90", + destructive: + "bg-destructive text-destructive-foreground hover:bg-destructive/90", + outline: + "border border-input bg-background hover:bg-accent hover:text-accent-foreground", + secondary: + "bg-secondary text-secondary-foreground hover:bg-secondary/80", + ghost: "hover:bg-accent hover:text-accent-foreground", + link: "text-primary underline-offset-4 hover:underline", + }, + size: { + default: "h-10 px-4 py-2", + sm: "h-9 rounded-md px-3", + lg: "h-11 rounded-md px-8", + icon: "h-10 w-10", + }, + }, + defaultVariants: { + variant: "default", + size: "default", + }, + } +) + +export interface ButtonProps + extends React.ButtonHTMLAttributes, + VariantProps { + asChild?: boolean +} + +const Button = React.forwardRef( + ({ className, variant, size, asChild = false, ...props }, ref) => { + const Comp = asChild ? Slot : "button" + return ( + + ) + } +) +Button.displayName = "Button" + +export { Button, buttonVariants } diff --git a/components/ui/card.tsx b/components/ui/card.tsx new file mode 100644 index 0000000..afa13ec --- /dev/null +++ b/components/ui/card.tsx @@ -0,0 +1,79 @@ +import * as React from "react" + +import { cn } from "@/lib/utils" + +const Card = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +Card.displayName = "Card" + +const CardHeader = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +CardHeader.displayName = "CardHeader" + +const CardTitle = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +

+)) +CardTitle.displayName = "CardTitle" + +const CardDescription = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +

+)) +CardDescription.displayName = "CardDescription" + +const CardContent = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +

+)) +CardContent.displayName = "CardContent" + +const CardFooter = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +CardFooter.displayName = "CardFooter" + +export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent } diff --git a/components/ui/form.tsx b/components/ui/form.tsx new file mode 100644 index 0000000..ce264ae --- /dev/null +++ b/components/ui/form.tsx @@ -0,0 +1,178 @@ +"use client" + +import * as React from "react" +import * as LabelPrimitive from "@radix-ui/react-label" +import { Slot } from "@radix-ui/react-slot" +import { + Controller, + ControllerProps, + FieldPath, + FieldValues, + FormProvider, + useFormContext, +} from "react-hook-form" + +import { cn } from "@/lib/utils" +import { Label } from "@/components/ui/label" + +const Form = FormProvider + +type FormFieldContextValue< + TFieldValues extends FieldValues = FieldValues, + TName extends FieldPath = FieldPath +> = { + name: TName +} + +const FormFieldContext = React.createContext( + {} as FormFieldContextValue +) + +const FormField = < + TFieldValues extends FieldValues = FieldValues, + TName extends FieldPath = FieldPath +>({ + ...props +}: ControllerProps) => { + return ( + + + + ) +} + +const useFormField = () => { + const fieldContext = React.useContext(FormFieldContext) + const itemContext = React.useContext(FormItemContext) + const { getFieldState, formState } = useFormContext() + + const fieldState = getFieldState(fieldContext.name, formState) + + if (!fieldContext) { + throw new Error("useFormField should be used within ") + } + + const { id } = itemContext + + return { + id, + name: fieldContext.name, + formItemId: `${id}-form-item`, + formDescriptionId: `${id}-form-item-description`, + formMessageId: `${id}-form-item-message`, + ...fieldState, + } +} + +type FormItemContextValue = { + id: string +} + +const FormItemContext = React.createContext( + {} as FormItemContextValue +) + +const FormItem = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => { + const id = React.useId() + + return ( + +
+ + ) +}) +FormItem.displayName = "FormItem" + +const FormLabel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => { + const { error, formItemId } = useFormField() + + return ( +