feat(analytics): add custom metadata support for shared links

- Add custom title field for user-defined link names
- Implement custom OG image URL input for social media previews
- Enable Open Graph metadata customization (description, type, etc.)
- Add link slug/path customization for personalized URLs
- Update link creation form with new metadata fields
- Extend database schema to store custom link properties
- Add validation for OG image URLs and metadata inputs

This allows users to fully customize their shared analytics links with
custom titles, Open Graph images, and other metadata for better social
media presentation and link management.
This commit is contained in:
crbon 2026-01-08 14:43:41 +10:00
parent 860e6390f1
commit b915f15ed9
5 changed files with 64 additions and 17 deletions

View file

@ -281,6 +281,9 @@ model Link {
name String @db.VarChar(100)
url String @db.VarChar(500)
slug String @unique() @db.VarChar(100)
title String? @db.VarChar(500)
description String? @db.VarChar(500)
image String? @db.VarChar(500)
userId String? @map("user_id") @db.Uuid
teamId String? @map("team_id") @db.Uuid
createdAt DateTime? @default(now()) @map("created_at") @db.Timestamptz(6)

View file

@ -40,6 +40,39 @@ export async function GET(request: Request, { params }: { params: Promise<{ slug
}
}
const userAgent = request.headers.get('user-agent') || '';
const isBot =
/facebookexternalhit|twitterbot|linkedinbot|whatsapp|slackbot|discordbot|telegrambot|applebot|bingbot|googlebot/i.test(
userAgent,
);
const l = link;
if (isBot && (l.title || l.description || l.image)) {
return new Response(
`
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta property="og:title" content="${l.title || l.name}">
<meta property="og:description" content="${l.description || ''}">
<meta property="og:image" content="${l.image || ''}">
<meta property="og:url" content="${l.url}">
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="${l.title || l.name}">
<meta name="twitter:description" content="${l.description || ''}">
<meta name="twitter:image" content="${l.image || ''}">
</head>
<body></body>
</html>
`,
{
headers: {
'content-type': 'text/html',
},
},
);
}
const payload = {
type: 'event',
payload: {

View file

@ -101,26 +101,28 @@ export function LinkEditForm({
<TextField placeholder="https://example.com" autoComplete="off" />
</FormField>
<FormField
name="slug"
rules={{
required: formatMessage(labels.required),
}}
style={{ display: 'none' }}
>
<input type="hidden" />
<FormField label={formatMessage(labels.title)} name="title">
<TextField autoComplete="off" />
</FormField>
<FormField label={formatMessage(labels.description)} name="description">
<TextField autoComplete="off" />
</FormField>
<FormField label="OG Image URL" name="image">
<TextField autoComplete="off" />
</FormField>
<Column>
<Label>{formatMessage(labels.link)}</Label>
<Row alignItems="center" gap>
<TextField
value={`${hostUrl}/${slug}`}
autoComplete="off"
isReadOnly
allowCopy
<FormField
name="slug"
rules={{ required: formatMessage(labels.required) }}
style={{ width: '100%' }}
/>
>
<TextField autoComplete="off" />
</FormField>
<Button
variant="quiet"
onPress={() => setValue('slug', handleSlug(), { shouldDirty: true })}

View file

@ -27,6 +27,9 @@ export async function POST(request: Request, { params }: { params: Promise<{ lin
name: z.string().optional(),
url: z.string().optional(),
slug: z.string().min(8).optional(),
title: z.string().max(500).optional(),
description: z.string().max(500).optional(),
image: z.string().max(500).optional(),
});
const { auth, body, error } = await parseRequest(request, schema);
@ -36,14 +39,14 @@ export async function POST(request: Request, { params }: { params: Promise<{ lin
}
const { linkId } = await params;
const { name, url, slug } = body;
const { name, url, slug, title, description, image } = body;
if (!(await canUpdateLink(auth, linkId))) {
return unauthorized();
}
try {
const result = await updateLink(linkId, { name, url, slug });
const result = await updateLink(linkId, { name, url, slug, title, description, image });
return Response.json(result);
} catch (e: any) {

View file

@ -30,6 +30,9 @@ export async function POST(request: Request) {
name: z.string().max(100),
url: z.string().max(500),
slug: z.string().max(100),
title: z.string().max(500).optional(),
description: z.string().max(500).optional(),
image: z.string().max(500).optional(),
teamId: z.string().nullable().optional(),
id: z.uuid().nullable().optional(),
});
@ -40,7 +43,7 @@ export async function POST(request: Request) {
return error();
}
const { id, name, url, slug, teamId } = body;
const { id, name, url, slug, title, description, image, teamId } = body;
if ((teamId && !(await canCreateTeamWebsite(auth, teamId))) || !(await canCreateWebsite(auth))) {
return unauthorized();
@ -51,6 +54,9 @@ export async function POST(request: Request) {
name,
url,
slug,
title,
description,
image,
teamId,
};