Skip to content
This repository was archived by the owner on Aug 5, 2026. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from 14 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 13 additions & 6 deletions ensawards.org/.env.local.example
Original file line number Diff line number Diff line change
@@ -1,8 +1,15 @@
# The ENSAdmin public service URL
# Link to the ENSNode instance available across the whole app.
# If not provided, the default public URL will point to
# the publicly hosted Alpha ENSNode instance.
ENSNODE_URL=https://api.alpha.ensnode.io/
# URL of the ENSNode instance to fetch ENS data from.
# This URL is used for all ENSNode connections across the whole app, excluding only unit test files.
Comment thread
Y3drk marked this conversation as resolved.
Outdated
# NOTE: This value is publicly exposed to anyone loading the app as it is made available to
# client-side components running in the browser.
# Optional. If not set, defaults to `DEFAULT_ENSNODE_URL` (https://api.alpha.ensnode.io/).
PUBLIC_ENSNODE_URL=https://api.alpha.ensnode.io/

# The ENSAdmin public service URL specifically for testing,
# Contract data validation
# The ENSNode public service URL specifically for validating that for all contracts stored in @/data/contracts.ts
# their cached ENS identity matches the current state in ENS.
# Used only in @/data/contracts.test.ts.
# Available only in server-side code or Astro build configuration. Not exposed to the browser.
# Separated from PUBLIC_ENSNODE_URL to facilitate integration with Vitest.
# Optional. If not set, defaults to `DEFAULT_ENSNODE_URL` (https://api.alpha.ensnode.io/).
VITE_ENSNODE_URL=https://api.alpha.ensnode.io/
Comment thread
Y3drk marked this conversation as resolved.
Outdated
2 changes: 1 addition & 1 deletion ensawards.org/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
"dependencies": {
"@astrojs/react": "^4.3.0",
"@astrojs/sitemap": "^3.6.0",
"@ensnode/datasources": "^0.35.0",
"@ensnode/datasources": "^0.36.0",
"@ensnode/ensnode-sdk": "^0.36.0",
"@headlessui/react": "^2.2.7",
"@heroicons/react": "^2.2.0",
Expand Down
22 changes: 15 additions & 7 deletions ensawards.org/src/components/atoms/form-elements/FormButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,12 +37,12 @@ export const FormButton = React.forwardRef<HTMLButtonElement, FormButtonProps>(

const content = (
<>
<span className={loading ? "opacity-0" : undefined}>{children}</span>
{loading && (
<div className="absolute inset-0 flex items-center justify-center">
<div className="flex items-center justify-center">
<Spinner />
</div>
)}
<span>{children}</span>
</>
);

Expand All @@ -57,12 +57,20 @@ export const FormButton = React.forwardRef<HTMLButtonElement, FormButtonProps>(
FormButton.displayName = "Button";

const Spinner = () => (
<svg className="animate-spin h-5 w-5" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<svg
xmlns="http://www.w3.org/2000/svg"
width="14"
height="14"
viewBox="0 0 14 14"
fill="none"
className="animate-spin"
>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
d="M12.6641 6.66533C12.664 7.93239 12.2628 9.16691 11.518 10.1919C10.7732 11.217 9.72304 11.9799 8.51798 12.3714C7.31292 12.7629 6.01486 12.7629 4.80982 12.3713C3.60479 11.9798 2.55465 11.2168 1.80991 10.1917C1.06516 9.16659 0.664053 7.93205 0.664063 6.665C0.664072 5.39794 1.0652 4.16341 1.80996 3.13833C2.55471 2.11326 3.60487 1.35027 4.80991 0.958718C6.01495 0.567164 7.31301 0.567146 8.51806 0.958667"
stroke="currentColor"
strokeWidth="1.33"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
143 changes: 93 additions & 50 deletions ensawards.org/src/components/molecules/ReferralLinkForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,46 +3,36 @@ import { FormButton } from "@/components/atoms/form-elements/FormButton.tsx";
import { Input } from "@/components/atoms/form-elements/Input.tsx";
import type { FormField, ValidationErrors } from "@/components/molecules/form/types.ts";
import { shadcnButtonVariants } from "@/components/ui/shadcnButtonStyles.ts";
import { capitalizeFormLabel, truncateAddress } from "@/utils";
import { capitalizeFormLabel } from "@/utils";
import { resolveEthAddress } from "@/utils/resolution.ts";
import { cn } from "@/utils/tailwindClassConcatenation.ts";
import { Link2 as LinkIcon, RefreshCw as RefreshIcon } from "lucide-react";
import React, { type FormEvent, useEffect, useState } from "react";
import { type NormalizedName } from "@ensnode/ensnode-sdk";
import { CircleAlertIcon, Link2 as LinkIcon, RefreshCw as RefreshIcon } from "lucide-react";
import React, { type FormEvent, useState } from "react";
import { type Address, getAddress, isAddress } from "viem";
import { normalize } from "viem/ens";
import * as Yup from "yup";

interface ReferralLinkFormDataProps {
"ethereum address": string;
"referral award recipient": string;
}

const formFields: FormField[] = [
{
label: "ethereum address",
label: "referral award recipient",
type: "text",
required: true,
placeholder: "Enter your ENS name or Ethereum Mainnet address",
},
];

enum ENSAwardsReferralLinkFormFields {
EthereumAddress = "ethereum address",
ReferralAwardRecipient = "referral award recipient",
}

// Very rudimentary validation here so that the more advanced one can be performed later in submit
const generateReferralLinkFormSchema = Yup.object().shape({
"ethereum address": Yup.string()
.required("Address is required")
.test({
name: "address-test",
exclusive: false,
test(value, ctx) {
if (value.length === 0) {
return ctx.createError({ message: "Address is required" });
}
if (!isAddress(value, { strict: false })) {
return ctx.createError({ message: "Invalid address" });
}

return true;
},
}),
"referral award recipient": Yup.string().required("Name or address is required"),
});

const getInitialValidationErrorsState = (formFields: FormField[]): ValidationErrors => {
Expand All @@ -67,64 +57,110 @@ function buildEnsReferralUrl(address: Address): URL {

export function ReferralLinkForm() {
const [isLoading, setIsLoading] = useState(false);
const [overallFormErrorMessage, setOverallFormErrorMessage] = useState("");
const [successfulFormSubmit, setSuccessfulFormSubmit] = useState(false);
const [validationErrors, setValidationErrors] = useState<ValidationErrors>(
getInitialValidationErrorsState(formFields),
);
const [referrerAddress, setReferrerAddress] = useState<string>("");
const [generatedLink, setGeneratedLink] = useState<string>("");

const submitForm = async (e: FormEvent) => {
setOverallFormErrorMessage("");
e.preventDefault();
setIsLoading(true);

const formData: FormData = new FormData(e.target as HTMLFormElement);

const data: ReferralLinkFormDataProps = {
"ethereum address": formData.get("ethereum address")?.toString() || "",
"referral award recipient":
formData.get(ENSAwardsReferralLinkFormFields.ReferralAwardRecipient)?.toString() || "",
};

// Validate form data against the schema (initial validation)
try {
// Validate form data against the schema
await generateReferralLinkFormSchema.validate(data, {
abortEarly: false,
});
} catch (validationError) {
// Check for initial validation error
if (validationError instanceof Yup.ValidationError) {
const singleInputError = validationError.inner[0];
setInputError(singleInputError.message);
}
setIsLoading(false);
return;
}

// Proceed with detailed validation if the initial one is successful
const recipientInput = data[ENSAwardsReferralLinkFormFields.ReferralAwardRecipient];

// Proceed with form submission if validation is successful
setReferrerAddress(data[ENSAwardsReferralLinkFormFields.EthereumAddress]);
// Check if the input is a valid address
if (isAddress(recipientInput, { strict: false })) {
// Interpret the input as an address to generate the referral link
setGeneratedLink(buildEnsReferralUrl(recipientInput).href);
setSuccessfulFormSubmit(true);

// Reset validation errors on successful validation
setValidationErrors(getInitialValidationErrorsState(formFields));

// Generate the referral link (we know that input data is a valid address)
setGeneratedLink(
buildEnsReferralUrl(data[ENSAwardsReferralLinkFormFields.EthereumAddress] as Address).href,
);
setIsLoading(false);
return;
}

// Check if the input is a "normalizable" ENS name
let normalizedName: NormalizedName;

try {
normalizedName = normalize(recipientInput) as NormalizedName;
} catch (error) {
// Display a generic message (ignore the details on purpose)
setInputError("Invalid name or address");
setIsLoading(false);
return;
}

// The name was normalizable to `normalizedName` so proceed with resolution
try {
const resolvedAddress = await resolveEthAddress(normalizedName);

if (resolvedAddress === null) {
setInputError("No Ethereum Mainnet address configured for this name.");
setIsLoading(false);
return;
}

setGeneratedLink(buildEnsReferralUrl(resolvedAddress).href);

setSuccessfulFormSubmit(true);
} catch (validationError) {
if (validationError instanceof Yup.ValidationError) {
const errors: ValidationErrors = getInitialValidationErrorsState(formFields);
for (const err of validationError.inner) {
if (
err.path &&
Object.values(ENSAwardsReferralLinkFormFields).includes(
err.path as ENSAwardsReferralLinkFormFields,
)
) {
errors[err.path as ENSAwardsReferralLinkFormFields] = err.message;
}
}

setValidationErrors(errors);

setValidationErrors(getInitialValidationErrorsState(formFields));
} catch (error) {
// Handle all possible errors of the resolution
if (error instanceof TypeError) {
// Likely a network error
console.error("Network error: ", error);
setOverallFormErrorMessage("Connection lost. Please check your connection and try again.");
} else if (error instanceof Error) {
console.log(error);
setOverallFormErrorMessage(error.message);
} else {
setOverallFormErrorMessage("Request error. Please try again.");
}
} finally {
setIsLoading(false);
}

setIsLoading(false);
};

const setInputError = (message: string) => {
const errors: ValidationErrors = getInitialValidationErrorsState(formFields);
errors[ENSAwardsReferralLinkFormFields.ReferralAwardRecipient] = message;
setValidationErrors(errors);
};

const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const { name } = e.target;
setValidationErrors({ ...validationErrors, [name]: "" });
setOverallFormErrorMessage("");
};

const verticalFlex = "flex flex-col justify-start items-center";
Expand All @@ -136,6 +172,12 @@ export function ReferralLinkForm() {
verticalFlex,
)}
>
{overallFormErrorMessage && (
<span className="flex flex-row justify-start items-center gap-3 py-3 px-4 rounded-lg border border-input bg-white self-stretch">
<CircleAlertIcon className="text-destructive h-4 w-4" />
<p className="text-destructive font-medium text-sm">{overallFormErrorMessage}</p>
</span>
)}
<div className={cn(verticalFlex, "gap-5")}>
<div className="w-12 h-12 flex flex-col justify-center items-center bg-[rgba(0,82,204,0.20)] rounded-full">
<LinkIcon size={20} className="text-blue-600 flex-shrink-0" />
Expand Down Expand Up @@ -206,7 +248,7 @@ export function ReferralLinkForm() {
type={field.type}
disabled={isLoading}
name={field.label}
placeholder="Enter your address"
placeholder={field.placeholder}
autoComplete="off"
onChange={handleInputChange}
error={validationErrors[field.label]}
Expand All @@ -217,11 +259,12 @@ export function ReferralLinkForm() {
))}
<FormButton
disabled={isLoading}
loading={isLoading}
type="submit"
variant="outline"
className="cursor-pointer self-stretch"
>
{isLoading ? "Generating..." : "Generate link"}
{isLoading ? "Loading" : "Generate link"}
</FormButton>
</div>
)}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -429,6 +429,7 @@ export const ContactForm = ({ whatsSuggested, formFields, submissionEndpoint }:
</FormButton>
<FormButton
disabled={isLoading}
loading={isLoading}
type="submit"
className="cursor-pointer rounded-full"
>
Expand Down
1 change: 1 addition & 0 deletions ensawards.org/src/components/molecules/form/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,5 @@ export interface FormField {
label: string;
type: "text" | "url";
required: boolean;
placeholder?: string;
}
2 changes: 1 addition & 1 deletion ensawards.org/src/data/contracts.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import {
type EnsProfileForContract,
} from "@/types/contracts.ts";
import { getChainName } from "@/utils/chains.ts";
import { getENSNodeUrlForTests } from "@/utils/envVariables.ts";
import { getENSNodeUrlForTests } from "@/utils/env/testAccess.ts";
import {
type ChainId,
ENSNodeClient,
Expand Down
7 changes: 7 additions & 0 deletions ensawards.org/src/env.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
interface ImportMetaEnv {
readonly PUBLIC_ENSNODE_URL: string;
}

interface ImportMeta {
readonly env: ImportMetaEnv;
}
4 changes: 4 additions & 0 deletions ensawards.org/src/utils/env/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
/**
* Default ENSNode API endpoint URL
*/
export const DEFAULT_ENSNODE_URL = "https://api.alpha.ensnode.io" as const;
20 changes: 20 additions & 0 deletions ensawards.org/src/utils/env/onClientAccess.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { DEFAULT_ENSNODE_URL } from "@/utils/env/index.ts";

/**
* Returns the ENSNode public URL defined in the .env file
*
* If the env variable is undefined returns a default fallback.
*
* @throws if the value set for PUBLIC_ENSNODE_URL cannot be converted to a `URL`.
*/
Comment thread
Y3drk marked this conversation as resolved.
Outdated
export const getENSNodeUrl = (): URL => {
const maybeEnvVariableURL = import.meta.env.PUBLIC_ENSNODE_URL;

// Check for empty string is necessary due to GitHub's fallback mechanism
// https://docs.github.com/en/actions/reference/workflows-and-actions/contexts
if (maybeEnvVariableURL === undefined || maybeEnvVariableURL === "") {
return new URL(DEFAULT_ENSNODE_URL);
}

return new URL(maybeEnvVariableURL);
};
21 changes: 21 additions & 0 deletions ensawards.org/src/utils/env/testAccess.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { DEFAULT_ENSNODE_URL } from "@/utils/env/index.ts";

/**
* Returns the ENSNode public URL defined in the .env file
* specifically for unit testing.
*
* If the env variable is undefined returns a default fallback.
*
* @throws if the value set for VITE_ENSNODE_URL cannot be converted to a `URL`.
*/
export const getENSNodeUrlForTests = (): URL => {
const maybeEnvVariableURL = process.env.VITE_ENSNODE_URL;
Comment thread
Y3drk marked this conversation as resolved.
Outdated

// Check for empty string is necessary due to GitHub's fallback mechanism
// https://docs.github.com/en/actions/reference/workflows-and-actions/contexts
if (maybeEnvVariableURL === undefined || maybeEnvVariableURL === "") {
return new URL(DEFAULT_ENSNODE_URL);
}

return new URL(maybeEnvVariableURL);
};
Loading