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) name String @db.VarChar(100)
url String @db.VarChar(500) url String @db.VarChar(500)
slug String @unique() @db.VarChar(100) 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 userId String? @map("user_id") @db.Uuid
teamId String? @map("team_id") @db.Uuid teamId String? @map("team_id") @db.Uuid
createdAt DateTime? @default(now()) @map("created_at") @db.Timestamptz(6) 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 = { const payload = {
type: 'event', type: 'event',
payload: { payload: {

View file

@ -101,26 +101,28 @@ export function LinkEditForm({
<TextField placeholder="https://example.com" autoComplete="off" /> <TextField placeholder="https://example.com" autoComplete="off" />
</FormField> </FormField>
<FormField <FormField label={formatMessage(labels.title)} name="title">
name="slug" <TextField autoComplete="off" />
rules={{ </FormField>
required: formatMessage(labels.required),
}} <FormField label={formatMessage(labels.description)} name="description">
style={{ display: 'none' }} <TextField autoComplete="off" />
> </FormField>
<input type="hidden" />
<FormField label="OG Image URL" name="image">
<TextField autoComplete="off" />
</FormField> </FormField>
<Column> <Column>
<Label>{formatMessage(labels.link)}</Label> <Label>{formatMessage(labels.link)}</Label>
<Row alignItems="center" gap> <Row alignItems="center" gap>
<TextField <FormField
value={`${hostUrl}/${slug}`} name="slug"
autoComplete="off" rules={{ required: formatMessage(labels.required) }}
isReadOnly
allowCopy
style={{ width: '100%' }} style={{ width: '100%' }}
/> >
<TextField autoComplete="off" />
</FormField>
<Button <Button
variant="quiet" variant="quiet"
onPress={() => setValue('slug', handleSlug(), { shouldDirty: true })} 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(), name: z.string().optional(),
url: z.string().optional(), url: z.string().optional(),
slug: z.string().min(8).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); 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 { linkId } = await params;
const { name, url, slug } = body; const { name, url, slug, title, description, image } = body;
if (!(await canUpdateLink(auth, linkId))) { if (!(await canUpdateLink(auth, linkId))) {
return unauthorized(); return unauthorized();
} }
try { try {
const result = await updateLink(linkId, { name, url, slug }); const result = await updateLink(linkId, { name, url, slug, title, description, image });
return Response.json(result); return Response.json(result);
} catch (e: any) { } catch (e: any) {

View file

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