Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
48 changes: 48 additions & 0 deletions apps/web/src/modules/coin/CoinDetail/CoinInfo.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,27 @@ export const CoinInfo = ({
isLoading: isMediaTypeLoading,
} = useMediaType(animationUrl, metadata)

const propertyEntries = useMemo(() => {
if (!metadata) return []

if (metadata.properties) {
return Object.entries(metadata.properties)
.map(([key, value]) => ({ key: String(key).trim(), value: String(value).trim() }))
.filter((entry) => entry.key && entry.value)
}

if (!metadata.attributes?.length) {
return []
}

return metadata.attributes
.map((attribute) => ({
key: String(attribute.trait_type || '').trim(),
value: String(attribute.value ?? '').trim(),
}))
.filter((entry) => entry.key && entry.value)
}, [metadata])

// Determine what media to show - prefer animation_url over image
const shouldUseMediaPreview =
animationUrl && mediaType && animationFetchableUrl && !isMediaTypeLoading
Expand Down Expand Up @@ -334,6 +355,33 @@ export const CoinInfo = ({
</>
)}

{/* Properties */}
{propertyEntries.length > 0 && (
<Box mb="x3">
<Text variant="label-sm" color="text3" mb="x2">
Properties
</Text>
<Flex wrap="wrap" gap="x2">
{propertyEntries.map((property) => (
<Box
key={`${property.key}-${property.value}`}
px="x3"
py="x2"
borderRadius="curved"
borderStyle="solid"
borderWidth="normal"
borderColor="border"
backgroundColor="background1"
>
<Text variant="paragraph-sm" color="text2">
{property.key}: {property.value}
</Text>
</Box>
))}
</Flex>
</Box>
)}

{/* Created Date */}
{createdAt && (
<>
Expand Down
12 changes: 8 additions & 4 deletions packages/ipfs-service/src/metadata.ts
Original file line number Diff line number Diff line change
@@ -1,20 +1,24 @@
import { getFetchableUrls } from './gateway'

export interface IpfsMetadataAttribute {
trait_type: string
value: string | number | boolean | null
display_type?: string
}

export interface IpfsMetadata {
name?: string
description?: string
image?: string
imageUrl?: string
animation_url?: string
external_url?: string
properties?: Record<string, string | number | boolean | null>
// Media type fields
media_type?: string
content_type?: string
mimeType?: string
attributes?: Array<{
trait_type: string
value: string | number
}>
attributes?: IpfsMetadataAttribute[]
}

/**
Expand Down
193 changes: 174 additions & 19 deletions packages/ui/src/CoinForm/CoinFormFields.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { DEFAULT_CLANKER_TARGET_FDV } from '@buildeross/utils'
import { Box, Flex, Stack, Text } from '@buildeross/zord'
import { Box, Button, Flex, Icon, Stack, Text } from '@buildeross/zord'
import React from 'react'

import FieldError from '../Fields/FieldError'
import NumberInput from '../Fields/NumberInput'
import TextArea from '../Fields/TextArea'
import TextInput from '../Fields/TextInput'
Expand All @@ -19,6 +20,121 @@ export const CoinFormFields: React.FC<CoinFormFieldsProps> = ({
}) => {
const [showAdvancedFdv, setShowAdvancedFdv] = React.useState(false)
const [showPropertiesSection, setShowPropertiesSection] = React.useState(false)
const propertyRowIdRef = React.useRef(0)
const [propertyRows, setPropertyRows] = React.useState<
Array<{
id: number
key: string
value: string
}>
>([])

const getRowsFromProperties = React.useCallback(
(properties?: Record<string, string>) => {
const rows = Object.entries(properties || {}).map(([key, value]) => ({
id: propertyRowIdRef.current++,
key,
value,
}))

if (rows.length === 0) {
return [{ id: propertyRowIdRef.current++, key: '', value: '' }]
}

return rows
},
[]
)

const validatePropertyRows = React.useCallback(
(rows: Array<{ id: number; key: string; value: string }>) => {
const keySet = new Set<string>()

for (const row of rows) {
const key = row.key.trim()
const value = row.value.trim()

if (!key && !value) {
continue
}

if (!key || !value) {
return 'Each custom property needs both a key and a value.'
}

const normalizedKey = key.toLowerCase()
if (keySet.has(normalizedKey)) {
return 'Custom property keys must be unique.'
}

keySet.add(normalizedKey)
}

return undefined
},
[]
)

const rowsToProperties = React.useCallback(
(rows: Array<{ id: number; key: string; value: string }>) =>
rows.reduce<Record<string, string>>((acc, row) => {
const key = row.key.trim()
const value = row.value.trim()
if (key && value) {
acc[key] = value
}

return acc
}, {}),
[]
)

const arePropertiesEqual = React.useCallback(
(left: Record<string, string>, right: Record<string, string>) => {
const leftKeys = Object.keys(left)
const rightKeys = Object.keys(right)

if (leftKeys.length !== rightKeys.length) {
return false
}

return leftKeys.every((key) => left[key] === right[key])
},
[]
)

const syncPropertyRows = React.useCallback(
(rows: Array<{ id: number; key: string; value: string }>) => {
setPropertyRows(rows)

const properties = rowsToProperties(rows)

formik.setFieldTouched('properties', true, false)
formik.setFieldValue('properties', properties, false)
formik.setFieldError('properties', validatePropertyRows(rows))
},
[formik, rowsToProperties, validatePropertyRows]
)

const propertiesError =
formik.touched.properties && typeof formik.errors.properties === 'string'
? formik.errors.properties
: undefined

React.useEffect(() => {
const formikProperties = formik.values.properties || {}
const currentProperties = rowsToProperties(propertyRows)

if (!arePropertiesEqual(formikProperties, currentProperties)) {
setPropertyRows(getRowsFromProperties(formikProperties))
}
}, [
arePropertiesEqual,
formik.values.properties,
getRowsFromProperties,
propertyRows,
rowsToProperties,
])

// Handle media upload to store mime type
const handleMediaUploadStart = React.useCallback(
Expand Down Expand Up @@ -199,49 +315,87 @@ export const CoinFormFields: React.FC<CoinFormFieldsProps> = ({
on={showPropertiesSection}
onToggle={() => {
const next = !showPropertiesSection
if (!next) formik.setFieldValue('properties', {})
if (!next) {
setPropertyRows([])
formik.setFieldTouched('properties', false, false)
formik.setFieldValue('properties', {}, false)
formik.setFieldError('properties', undefined)
}
if (next && propertyRows.length === 0) {
setPropertyRows(getRowsFromProperties(formik.values.properties))
}
setShowPropertiesSection(next)
}}
/>
</Flex>

{showPropertiesSection && (
<Flex direction="column" gap="x2">
{Object.entries(formik.values.properties || {}).map(
([key, value], index) => (
<Flex key={index} gap="x2">
{propertyRows.map((row, index) => (
<Flex key={row.id} gap="x2" align="center">
<Box flex={1} style={{ marginBottom: '-32px' }}>
<TextInput
id={`property-key-${index}`}
value={key}
value={row.key}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
const newProps = { ...formik.values.properties }
delete newProps[key]
newProps[e.target.value] = value as string
formik.setFieldValue('properties', newProps)
const nextRows = propertyRows.map((item) =>
item.id === row.id ? { ...item, key: e.target.value } : item
)

syncPropertyRows(nextRows)
}}
placeholder="Key"
formik={formik}
/>
</Box>
<Box flex={1} style={{ marginBottom: '-32px' }}>
<TextInput
id={`property-value-${index}`}
value={value as string}
value={row.value}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
const newProps = { ...formik.values.properties }
newProps[key] = e.target.value
formik.setFieldValue('properties', newProps)
const nextRows = propertyRows.map((item) =>
item.id === row.id ? { ...item, value: e.target.value } : item
)

syncPropertyRows(nextRows)
}}
placeholder="Value"
formik={formik}
/>
</Flex>
)
)}
</Box>
{propertyRows.length > 1 && (
<Flex align="center" justify="center">
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => {
const nextRows = propertyRows.filter(
(item) => item.id !== row.id
)
syncPropertyRows(nextRows)
}}
style={{
minWidth: '32px',
paddingLeft: '4px',
paddingRight: '4px',
}}
aria-label={`Remove property ${index + 1}`}
>
<Icon id="cross" />
</Button>
</Flex>
)}
</Flex>
))}
<Box
as="button"
type="button"
onClick={() => {
const newProps = { ...formik.values.properties, '': '' }
formik.setFieldValue('properties', newProps)
syncPropertyRows([
...propertyRows,
{ id: propertyRowIdRef.current++, key: '', value: '' },
])
}}
style={{
padding: '8px',
Expand All @@ -253,6 +407,7 @@ export const CoinFormFields: React.FC<CoinFormFieldsProps> = ({
>
+ Add Property
</Box>
{propertiesError && <FieldError message={propertiesError} />}
</Flex>
)}
</Box>
Expand Down
5 changes: 4 additions & 1 deletion packages/ui/src/LikeButton/LikeButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ interface LikeButtonProps {
onLikeSuccess?: (txHash: string, amount: bigint) => void
}

type LikePopupMode = 'like' | 'alreadyLiked'

const LikeButton: React.FC<LikeButtonProps> = ({
coinAddress,
// symbol,
Expand Down Expand Up @@ -66,6 +68,7 @@ const LikeButton: React.FC<LikeButtonProps> = ({
const px = effectiveSize === 'lg' ? 'x6' : effectiveSize === 'xs' ? 'x3' : 'x4'

const isLiked = hasBalance || justLiked
const popupInitialMode: LikePopupMode = isLiked ? 'alreadyLiked' : 'like'

// Determine which heart icon to show
const heartIcon = isLiked ? 'heartFilled' : 'heart'
Expand Down Expand Up @@ -108,7 +111,6 @@ const LikeButton: React.FC<LikeButtonProps> = ({
size={size}
style={{ minWidth: 'unset', opacity: 1 }}
px={px}
disabled={isLiked}
>
<motion.div
key={heartIcon}
Expand All @@ -135,6 +137,7 @@ const LikeButton: React.FC<LikeButtonProps> = ({
chainId={chainId}
onClose={handleClosePopup}
onLikeSuccess={onLikeSuccessInner}
initialMode={popupInitialMode}
/>
)}
</PopUp>
Expand Down
Loading
Loading