diff --git a/src/assets/social/DynamicSocialIcon.tsx b/src/assets/social/DynamicSocialIcon.tsx
index c662b8b32..ebe9606e3 100644
--- a/src/assets/social/DynamicSocialIcon.tsx
+++ b/src/assets/social/DynamicSocialIcon.tsx
@@ -13,9 +13,14 @@ export const socialIconTypes = {
'com.youtube': dynamic(() => import('./SocialYoutube.svg')),
'org.telegram': dynamic(() => import('./SocialTelegram.svg')),
'xyz.mirror': dynamic(() => import('./SocialMirrorColour.svg')),
+ 'xyz.poap': dynamic(() => import('./SocialPoap.svg')),
+ 'xyz.farcaster': dynamic(() => import('./SocialFarcaster.svg')),
+ 'co.zora': dynamic(() => import('./SocialZora.svg')),
email: dynamic(() => import('@ensdomains/thorin').then((m) => m.EnvelopeSVG)),
}
+export type SocialIconType = keyof typeof socialIconTypes
+
export const socialIconColors = {
'com.discord': '#5A57DD',
'com.discourse': undefined,
@@ -25,6 +30,9 @@ export const socialIconColors = {
'com.youtube': '#FF0000',
'org.telegram': '#2BABEE',
'xyz.mirror': undefined,
+ 'xyz.poap': '#6534FF',
+ 'xyz.farcaster': '##6A3CFF',
+ 'co.zora': undefined,
email: '#000000',
}
diff --git a/src/assets/social/SocialFarcaster.svg b/src/assets/social/SocialFarcaster.svg
new file mode 100644
index 000000000..74dacc32d
--- /dev/null
+++ b/src/assets/social/SocialFarcaster.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/src/assets/social/SocialPoap.svg b/src/assets/social/SocialPoap.svg
new file mode 100644
index 000000000..abffb92f8
--- /dev/null
+++ b/src/assets/social/SocialPoap.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/src/assets/social/SocialZora.svg b/src/assets/social/SocialZora.svg
new file mode 100644
index 000000000..2bd12fda3
--- /dev/null
+++ b/src/assets/social/SocialZora.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/src/constants/customAccountRecordKeys.ts b/src/constants/customAccountRecordKeys.ts
new file mode 100644
index 000000000..2b4817adc
--- /dev/null
+++ b/src/constants/customAccountRecordKeys.ts
@@ -0,0 +1,111 @@
+/**
+ * Custom Account Records Configuration
+ *
+ * This file contains all configuration for custom account record types (like POAP).
+ * It serves as a model for how social accounts could be refactored in the future,
+ * with all data consolidated in one location.
+ */
+
+import { type SocialIconType } from '@app/assets/social/DynamicSocialIcon'
+import { normaliseTwitterRecordValue } from '@app/utils/records/normaliseTwitterRecordValue'
+
+/* eslint-disable @typescript-eslint/naming-convention */
+
+/**
+ * Configuration object for a custom account record type
+ */
+export type CustomAccountConfig = {
+ key: K
+ icon: SocialIconType
+ color?: string
+ label: string
+ type: 'link' | 'copy'
+ getUrl?: (value: string) => string
+ getValue?: (value: string) => string
+}
+
+/**
+ * Registry of all custom account record configurations.
+ * This is the single source of truth - ensures every key has exactly one config.
+ *
+ * To add a new custom account:
+ * 1. Add a new property to this registry object
+ * 2. Register the icon in src/assets/social/DynamicSocialIcon.tsx
+ * 3. Create the corresponding SVG file
+ */
+const customAccountRegistry = {
+ 'xyz.poap': {
+ key: 'xyz.poap',
+ icon: 'xyz.poap',
+ color: '#6534FF',
+ label: 'POAP',
+ type: 'link',
+ getUrl: (value) => `https://collectors.poap.xyz/scan/${value}`,
+ },
+ 'xyz.farcaster': {
+ key: 'xyz.farcaster',
+ icon: 'xyz.farcaster',
+ color: '#6A3CFF',
+ label: 'Farcaster',
+ type: 'link',
+ getUrl: (value) => `https://farcaster.xyz/${value}`,
+ },
+ 'co.zora': {
+ key: 'co.zora',
+ icon: 'co.zora',
+ label: 'Zora',
+ type: 'link',
+ getValue: normaliseTwitterRecordValue,
+ getUrl: (value) => `https://zora.co/@${value}`,
+ },
+} as const satisfies Record
+
+/**
+ * TypeScript type for custom account record keys.
+ * Derived directly from the registry to ensure consistency.
+ */
+export type CustomAccountRecordKey = keyof typeof customAccountRegistry
+
+/**
+ * Array of custom account record keys.
+ * Derived from the registry - do not modify directly.
+ */
+export const customAccountRecordKeys = Object.keys(
+ customAccountRegistry,
+) as CustomAccountRecordKey[]
+
+/**
+ * Array of custom account configurations.
+ * Derived from the registry - do not modify directly.
+ */
+export const customAccountRecords: ReadonlyArray> =
+ Object.values(customAccountRegistry)
+
+/**
+ * Map for O(1) config lookups
+ */
+const customAccountConfigMap = new Map(customAccountRecords.map((config) => [config.key, config]))
+
+/**
+ * Check if a key is a custom account record key
+ */
+export function isCustomAccountKey(key: string): key is CustomAccountRecordKey {
+ return customAccountConfigMap.has(key as CustomAccountRecordKey)
+}
+
+/**
+ * Get display data for a custom account
+ */
+export function getCustomAccountData(key: string, value: string) {
+ const config = customAccountConfigMap.get(key as CustomAccountRecordKey)
+ if (!config) return null
+
+ return {
+ icon: config.icon,
+ color: config.color,
+ label: config.label,
+ value: config.getValue ? config.getValue(value) : value,
+ type: config.type,
+ urlFormatter: config.getUrl?.(value),
+ }
+}
diff --git a/src/utils/getSocialData.ts b/src/utils/getSocialData.ts
index 2f643c4bd..72019a3ed 100644
--- a/src/utils/getSocialData.ts
+++ b/src/utils/getSocialData.ts
@@ -1,57 +1,94 @@
+import { getCustomAccountData } from '@app/constants/customAccountRecordKeys'
+
import { normaliseTwitterRecordValue } from './records/normaliseTwitterRecordValue'
-export const getSocialData = (iconKey: string, value: string) => {
- switch (iconKey) {
- case 'twitter':
- case 'com.twitter':
- case 'x':
- case 'com.x':
- return {
- icon: 'com.twitter',
- color: '#000000',
- label: 'X',
- value: normaliseTwitterRecordValue(value),
- type: 'link',
- urlFormatter: `https://x.com/${value.replace(/^@/, '')}`,
- }
- case 'github':
- case 'com.github':
- return {
- icon: 'com.github',
- color: '#000000',
- label: 'GitHub',
- value,
- type: 'link',
- urlFormatter: `https://github.com/${value}`,
- }
- case 'discord':
- case 'com.discord':
- return {
- icon: 'com.discord',
- color: '#5A57DD',
- label: 'Discord',
- value,
- type: 'copy',
- }
- case 'telegram':
- case 'org.telegram':
- return {
- icon: 'org.telegram',
- color: '#2BABEE',
- label: 'Telegram',
- value,
- type: 'link',
- urlFormatter: `https://t.me/${value}`,
- }
- case 'email':
- return {
- icon: 'email',
- color: '#000000',
- label: 'Email',
- value,
- type: 'copy',
- }
- default:
- return null
+type SocialDataConfig = {
+ icon: string
+ color: string
+ label: string
+ type: 'link' | 'copy'
+ getValue?: (value: string) => string
+ getUrl?: (value: string) => string
+}
+
+// Define base configurations
+const twitterConfig: SocialDataConfig = {
+ icon: 'com.twitter',
+ color: '#000000',
+ label: 'X',
+ type: 'link',
+ getValue: normaliseTwitterRecordValue,
+ getUrl: (value) => `https://x.com/${value.replace(/^@/, '')}`,
+}
+
+const githubConfig: SocialDataConfig = {
+ icon: 'com.github',
+ color: '#000000',
+ label: 'GitHub',
+ type: 'link',
+ getUrl: (value) => `https://github.com/${value}`,
+}
+
+const discordConfig: SocialDataConfig = {
+ icon: 'com.discord',
+ color: '#5A57DD',
+ label: 'Discord',
+ type: 'copy',
+}
+
+const telegramConfig: SocialDataConfig = {
+ icon: 'org.telegram',
+ color: '#2BABEE',
+ label: 'Telegram',
+ type: 'link',
+ getUrl: (value) => `https://t.me/${value}`,
+}
+
+/* eslint-disable @typescript-eslint/naming-convention */
+const socialDataMap: Record = {
+ // Twitter/X aliases
+ twitter: twitterConfig,
+ 'com.twitter': twitterConfig,
+ x: twitterConfig,
+ 'com.x': twitterConfig,
+
+ // GitHub aliases
+ github: githubConfig,
+ 'com.github': githubConfig,
+
+ // Discord aliases
+ discord: discordConfig,
+ 'com.discord': discordConfig,
+
+ // Telegram aliases
+ telegram: telegramConfig,
+ 'org.telegram': telegramConfig,
+
+ // Email
+ email: {
+ icon: 'email',
+ color: '#000000',
+ label: 'Email',
+ type: 'copy',
+ },
+}
+/* eslint-enable @typescript-eslint/naming-convention */
+
+export function getSocialData(iconKey: string, value: string) {
+ // Check custom account records first
+ const customData = getCustomAccountData(iconKey, value)
+ if (customData) return customData
+
+ // Then check standard social records
+ const config = socialDataMap[iconKey]
+ if (!config) return null
+
+ return {
+ icon: config.icon,
+ color: config.color,
+ label: config.label,
+ value: config.getValue?.(value) ?? value,
+ type: config.type,
+ urlFormatter: config.getUrl?.(value),
}
}
diff --git a/src/utils/records/categoriseProfileTextRecords.ts b/src/utils/records/categoriseProfileTextRecords.ts
index 0b2f45b96..4e08c2ba7 100644
--- a/src/utils/records/categoriseProfileTextRecords.ts
+++ b/src/utils/records/categoriseProfileTextRecords.ts
@@ -1,5 +1,6 @@
import type { DecodedText } from '@ensdomains/ensjs/dist/types'
+import { isCustomAccountKey } from '@app/constants/customAccountRecordKeys'
import {
supportedGeneralRecordKeys,
SupportedGeneralRecordsKey,
@@ -44,10 +45,12 @@ export const categoriseAndTransformTextRecords = ({
}>(
(acc, record) => {
const normalisedRecord = normaliseProfileAccountsRecord(record)
+ // Check if it's a supported social record OR a custom account key
if (
supportedSocialRecordKeys.includes(
normalisedRecord.normalisedKey as unknown as SupportedSocialRecordKey,
- )
+ ) ||
+ isCustomAccountKey(record.key)
) {
const normalisedRecordWithVerifications =
appendVerificationProps?.(normalisedRecord) || normalisedRecord