Add sharing to pixel and link detail pages
This commit is contained in:
@@ -1,19 +1,31 @@
|
||||
import { Row } from '@umami/react-zen';
|
||||
import { IconLabel } from '@/components/common/IconLabel';
|
||||
import { LinkButton } from '@/components/common/LinkButton';
|
||||
import { PageHeader } from '@/components/common/PageHeader';
|
||||
import { useLink, useMessages, useSlug } from '@/components/hooks';
|
||||
import { ExternalLink, Link } from '@/components/icons';
|
||||
import { LinkShareButton } from './LinkShareButton';
|
||||
|
||||
export function LinkHeader() {
|
||||
const { t, labels } = useMessages();
|
||||
const { getSlugUrl } = useSlug('link');
|
||||
export function LinkHeader({ showActions = true }: { showActions?: boolean }) {
|
||||
const link = useLink();
|
||||
|
||||
return (
|
||||
<PageHeader title={link.name} description={link.url} icon={<Link />}>
|
||||
<LinkButton href={getSlugUrl(link.slug)} target="_blank" prefetch={false} asAnchor>
|
||||
<IconLabel icon={<ExternalLink />} label={t(labels.view)} />
|
||||
</LinkButton>
|
||||
{showActions && link.id && <LinkHeaderActions linkId={link.id} slug={link.slug} />}
|
||||
</PageHeader>
|
||||
);
|
||||
}
|
||||
|
||||
function LinkHeaderActions({ linkId, slug }: { linkId: string; slug: string }) {
|
||||
const { t, labels } = useMessages();
|
||||
const { getSlugUrl } = useSlug('link');
|
||||
|
||||
return (
|
||||
<Row alignItems="center" gap="3">
|
||||
<LinkShareButton linkId={linkId} />
|
||||
<LinkButton href={getSlugUrl(slug)} target="_blank" prefetch={false} asAnchor>
|
||||
<IconLabel icon={<ExternalLink />} label={t(labels.view)} />
|
||||
</LinkButton>
|
||||
</Row>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -12,13 +12,19 @@ import { Panel } from '@/components/common/Panel';
|
||||
|
||||
const excludedIds = ['path', 'entry', 'exit', 'title', 'language', 'screen', 'event'];
|
||||
|
||||
export function LinkPage({ linkId }: { linkId: string }) {
|
||||
export function LinkPage({
|
||||
linkId,
|
||||
showHeaderActions = true,
|
||||
}: {
|
||||
linkId: string;
|
||||
showHeaderActions?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<LinkProvider linkId={linkId}>
|
||||
<Grid width="100%" height="100%">
|
||||
<Column margin="2">
|
||||
<PageBody gap>
|
||||
<LinkHeader />
|
||||
<LinkHeader showActions={showHeaderActions} />
|
||||
<LinkControls linkId={linkId} />
|
||||
<LinkMetricsBar linkId={linkId} showChange={true} />
|
||||
<Panel>
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import { useMessages } from '@/components/hooks';
|
||||
import { Share } from '@/components/icons';
|
||||
import { DialogButton } from '@/components/input/DialogButton';
|
||||
import { LinkShareDialog } from './LinkShareDialog';
|
||||
|
||||
export function LinkShareButton({ linkId }: { linkId: string }) {
|
||||
const { t, labels } = useMessages();
|
||||
|
||||
return (
|
||||
<DialogButton icon={<Share />} label={t(labels.share)} title={null} width="900px">
|
||||
<LinkShareDialog linkId={linkId} />
|
||||
</DialogButton>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { Button, Column, Heading, Row, Text } from '@umami/react-zen';
|
||||
import { useState } from 'react';
|
||||
import { LoadingPanel } from '@/components/common/LoadingPanel';
|
||||
import { IconLabel } from '@/components/common/IconLabel';
|
||||
import { useLinkSharesQuery, useMessages } from '@/components/hooks';
|
||||
import { Plus } from '@/components/icons';
|
||||
import { SimpleShareCreateForm } from '@/components/share/SimpleShareCreateForm';
|
||||
import { SimpleSharesTable } from '@/components/share/SimpleSharesTable';
|
||||
|
||||
export function LinkShareDialog({ linkId }: { linkId: string }) {
|
||||
const { data, error, isLoading } = useLinkSharesQuery({ linkId });
|
||||
const shares = data?.data || [];
|
||||
const hasShares = shares.length > 0;
|
||||
|
||||
return (
|
||||
<LoadingPanel data={data} isLoading={isLoading} error={error}>
|
||||
<LinkShareDialogContent linkId={linkId} hasShares={hasShares} shares={shares} />
|
||||
</LoadingPanel>
|
||||
);
|
||||
}
|
||||
|
||||
function LinkShareDialogContent({
|
||||
linkId,
|
||||
hasShares,
|
||||
shares,
|
||||
}: {
|
||||
linkId: string;
|
||||
hasShares: boolean;
|
||||
shares: any[];
|
||||
}) {
|
||||
const { t, labels, messages } = useMessages();
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
const showCreateForm = !hasShares || isCreating;
|
||||
|
||||
return (
|
||||
<Column gap="4">
|
||||
<Row justifyContent="space-between" alignItems="center">
|
||||
<Heading>{t(labels.share)}</Heading>
|
||||
{hasShares && !isCreating && (
|
||||
<Button variant="primary" onPress={() => setIsCreating(true)}>
|
||||
<IconLabel icon={<Plus size={16} />} label={t(labels.add)} />
|
||||
</Button>
|
||||
)}
|
||||
</Row>
|
||||
{showCreateForm && (
|
||||
<SimpleShareCreateForm
|
||||
createPath={`/links/${linkId}/shares`}
|
||||
onSave={() => setIsCreating(false)}
|
||||
onCancel={hasShares ? () => setIsCreating(false) : undefined}
|
||||
/>
|
||||
)}
|
||||
{hasShares ? (
|
||||
<>
|
||||
<Text>{t(messages.shareUrl)}</Text>
|
||||
<SimpleSharesTable data={shares} />
|
||||
</>
|
||||
) : (
|
||||
!showCreateForm && <Text color="muted">{t(messages.noDataAvailable)}</Text>
|
||||
)}
|
||||
</Column>
|
||||
);
|
||||
}
|
||||
@@ -1,19 +1,31 @@
|
||||
import { Row } from '@umami/react-zen';
|
||||
import { IconLabel } from '@/components/common/IconLabel';
|
||||
import { LinkButton } from '@/components/common/LinkButton';
|
||||
import { PageHeader } from '@/components/common/PageHeader';
|
||||
import { useMessages, usePixel, useSlug } from '@/components/hooks';
|
||||
import { ExternalLink, Grid2x2 } from '@/components/icons';
|
||||
import { PixelShareButton } from './PixelShareButton';
|
||||
|
||||
export function PixelHeader() {
|
||||
const { t, labels } = useMessages();
|
||||
const { getSlugUrl } = useSlug('pixel');
|
||||
export function PixelHeader({ showActions = true }: { showActions?: boolean }) {
|
||||
const pixel = usePixel();
|
||||
|
||||
return (
|
||||
<PageHeader title={pixel.name} icon={<Grid2x2 />}>
|
||||
<LinkButton href={getSlugUrl(pixel.slug)} target="_blank" prefetch={false} asAnchor>
|
||||
<IconLabel icon={<ExternalLink />} label={t(labels.view)} />
|
||||
</LinkButton>
|
||||
{showActions && pixel.id && <PixelHeaderActions pixelId={pixel.id} slug={pixel.slug} />}
|
||||
</PageHeader>
|
||||
);
|
||||
}
|
||||
|
||||
function PixelHeaderActions({ pixelId, slug }: { pixelId: string; slug: string }) {
|
||||
const { t, labels } = useMessages();
|
||||
const { getSlugUrl } = useSlug('pixel');
|
||||
|
||||
return (
|
||||
<Row alignItems="center" gap="3">
|
||||
<PixelShareButton pixelId={pixelId} />
|
||||
<LinkButton href={getSlugUrl(slug)} target="_blank" prefetch={false} asAnchor>
|
||||
<IconLabel icon={<ExternalLink />} label={t(labels.view)} />
|
||||
</LinkButton>
|
||||
</Row>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -12,13 +12,19 @@ import { Panel } from '@/components/common/Panel';
|
||||
|
||||
const excludedIds = ['path', 'entry', 'exit', 'title', 'language', 'screen', 'event'];
|
||||
|
||||
export function PixelPage({ pixelId }: { pixelId: string }) {
|
||||
export function PixelPage({
|
||||
pixelId,
|
||||
showHeaderActions = true,
|
||||
}: {
|
||||
pixelId: string;
|
||||
showHeaderActions?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<PixelProvider pixelId={pixelId}>
|
||||
<Grid width="100%" height="100%">
|
||||
<Column margin="2">
|
||||
<PageBody gap>
|
||||
<PixelHeader />
|
||||
<PixelHeader showActions={showHeaderActions} />
|
||||
<PixelControls pixelId={pixelId} />
|
||||
<PixelMetricsBar pixelId={pixelId} showChange={true} />
|
||||
<Panel>
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import { useMessages } from '@/components/hooks';
|
||||
import { Share } from '@/components/icons';
|
||||
import { DialogButton } from '@/components/input/DialogButton';
|
||||
import { PixelShareDialog } from './PixelShareDialog';
|
||||
|
||||
export function PixelShareButton({ pixelId }: { pixelId: string }) {
|
||||
const { t, labels } = useMessages();
|
||||
|
||||
return (
|
||||
<DialogButton icon={<Share />} label={t(labels.share)} title={null} width="900px">
|
||||
<PixelShareDialog pixelId={pixelId} />
|
||||
</DialogButton>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { Button, Column, Heading, Row, Text } from '@umami/react-zen';
|
||||
import { useState } from 'react';
|
||||
import { LoadingPanel } from '@/components/common/LoadingPanel';
|
||||
import { IconLabel } from '@/components/common/IconLabel';
|
||||
import { useMessages, usePixelSharesQuery } from '@/components/hooks';
|
||||
import { Plus } from '@/components/icons';
|
||||
import { SimpleShareCreateForm } from '@/components/share/SimpleShareCreateForm';
|
||||
import { SimpleSharesTable } from '@/components/share/SimpleSharesTable';
|
||||
|
||||
export function PixelShareDialog({ pixelId }: { pixelId: string }) {
|
||||
const { data, error, isLoading } = usePixelSharesQuery({ pixelId });
|
||||
const shares = data?.data || [];
|
||||
const hasShares = shares.length > 0;
|
||||
|
||||
return (
|
||||
<LoadingPanel data={data} isLoading={isLoading} error={error}>
|
||||
<PixelShareDialogContent pixelId={pixelId} hasShares={hasShares} shares={shares} />
|
||||
</LoadingPanel>
|
||||
);
|
||||
}
|
||||
|
||||
function PixelShareDialogContent({
|
||||
pixelId,
|
||||
hasShares,
|
||||
shares,
|
||||
}: {
|
||||
pixelId: string;
|
||||
hasShares: boolean;
|
||||
shares: any[];
|
||||
}) {
|
||||
const { t, labels, messages } = useMessages();
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
const showCreateForm = !hasShares || isCreating;
|
||||
|
||||
return (
|
||||
<Column gap="4">
|
||||
<Row justifyContent="space-between" alignItems="center">
|
||||
<Heading>{t(labels.share)}</Heading>
|
||||
{hasShares && !isCreating && (
|
||||
<Button variant="primary" onPress={() => setIsCreating(true)}>
|
||||
<IconLabel icon={<Plus size={16} />} label={t(labels.add)} />
|
||||
</Button>
|
||||
)}
|
||||
</Row>
|
||||
{showCreateForm && (
|
||||
<SimpleShareCreateForm
|
||||
createPath={`/pixels/${pixelId}/shares`}
|
||||
onSave={() => setIsCreating(false)}
|
||||
onCancel={hasShares ? () => setIsCreating(false) : undefined}
|
||||
/>
|
||||
)}
|
||||
{hasShares ? (
|
||||
<>
|
||||
<Text>{t(messages.shareUrl)}</Text>
|
||||
<SimpleSharesTable data={shares} />
|
||||
</>
|
||||
) : (
|
||||
!showCreateForm && <Text color="muted">{t(messages.noDataAvailable)}</Text>
|
||||
)}
|
||||
</Column>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { z } from 'zod';
|
||||
import { ENTITY_TYPE } from '@/lib/constants';
|
||||
import { uuid } from '@/lib/crypto';
|
||||
import { getRandomChars } from '@/lib/generate';
|
||||
import { parseRequest } from '@/lib/request';
|
||||
import { json, unauthorized } from '@/lib/response';
|
||||
import { filterParams, pagingParams } from '@/lib/schema';
|
||||
import { canUpdateLink, canViewLink } from '@/permissions';
|
||||
import { createShare, getSharesByEntityId } from '@/queries/prisma';
|
||||
|
||||
export async function GET(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ linkId: string }> },
|
||||
) {
|
||||
const schema = z.object({
|
||||
...filterParams,
|
||||
...pagingParams,
|
||||
});
|
||||
|
||||
const { auth, query, error } = await parseRequest(request, schema);
|
||||
|
||||
if (error) {
|
||||
return error();
|
||||
}
|
||||
|
||||
const { linkId } = await params;
|
||||
const { page, pageSize, search } = query;
|
||||
|
||||
if (!(await canViewLink(auth, linkId))) {
|
||||
return unauthorized();
|
||||
}
|
||||
|
||||
const data = await getSharesByEntityId(linkId, {
|
||||
page,
|
||||
pageSize,
|
||||
search,
|
||||
});
|
||||
|
||||
return json(data);
|
||||
}
|
||||
|
||||
export async function POST(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ linkId: string }> },
|
||||
) {
|
||||
const schema = z.object({
|
||||
name: z.string().max(200),
|
||||
});
|
||||
|
||||
const { auth, body, error } = await parseRequest(request, schema);
|
||||
|
||||
if (error) {
|
||||
return error();
|
||||
}
|
||||
|
||||
const { linkId } = await params;
|
||||
const { name } = body;
|
||||
|
||||
if (!(await canUpdateLink(auth, linkId))) {
|
||||
return unauthorized();
|
||||
}
|
||||
|
||||
const share = await createShare({
|
||||
id: uuid(),
|
||||
entityId: linkId,
|
||||
shareType: ENTITY_TYPE.link,
|
||||
name,
|
||||
slug: getRandomChars(16),
|
||||
parameters: {},
|
||||
});
|
||||
|
||||
return json(share);
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { z } from 'zod';
|
||||
import { ENTITY_TYPE } from '@/lib/constants';
|
||||
import { uuid } from '@/lib/crypto';
|
||||
import { getRandomChars } from '@/lib/generate';
|
||||
import { parseRequest } from '@/lib/request';
|
||||
import { json, unauthorized } from '@/lib/response';
|
||||
import { filterParams, pagingParams } from '@/lib/schema';
|
||||
import { canUpdatePixel, canViewPixel } from '@/permissions';
|
||||
import { createShare, getSharesByEntityId } from '@/queries/prisma';
|
||||
|
||||
export async function GET(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ pixelId: string }> },
|
||||
) {
|
||||
const schema = z.object({
|
||||
...filterParams,
|
||||
...pagingParams,
|
||||
});
|
||||
|
||||
const { auth, query, error } = await parseRequest(request, schema);
|
||||
|
||||
if (error) {
|
||||
return error();
|
||||
}
|
||||
|
||||
const { pixelId } = await params;
|
||||
const { page, pageSize, search } = query;
|
||||
|
||||
if (!(await canViewPixel(auth, pixelId))) {
|
||||
return unauthorized();
|
||||
}
|
||||
|
||||
const data = await getSharesByEntityId(pixelId, {
|
||||
page,
|
||||
pageSize,
|
||||
search,
|
||||
});
|
||||
|
||||
return json(data);
|
||||
}
|
||||
|
||||
export async function POST(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ pixelId: string }> },
|
||||
) {
|
||||
const schema = z.object({
|
||||
name: z.string().max(200),
|
||||
});
|
||||
|
||||
const { auth, body, error } = await parseRequest(request, schema);
|
||||
|
||||
if (error) {
|
||||
return error();
|
||||
}
|
||||
|
||||
const { pixelId } = await params;
|
||||
const { name } = body;
|
||||
|
||||
if (!(await canUpdatePixel(auth, pixelId))) {
|
||||
return unauthorized();
|
||||
}
|
||||
|
||||
const share = await createShare({
|
||||
id: uuid(),
|
||||
entityId: pixelId,
|
||||
shareType: ENTITY_TYPE.pixel,
|
||||
name,
|
||||
slug: getRandomChars(16),
|
||||
parameters: {},
|
||||
});
|
||||
|
||||
return json(share);
|
||||
}
|
||||
@@ -6,17 +6,17 @@ import prisma from '@/lib/prisma';
|
||||
import redis from '@/lib/redis';
|
||||
import { json, notFound } from '@/lib/response';
|
||||
import type { WhiteLabel } from '@/lib/types';
|
||||
import { getBoard, getShareByCode, getWebsite } from '@/queries/prisma';
|
||||
import { getBoard, getLink, getPixel, getShareByCode, getWebsite } from '@/queries/prisma';
|
||||
|
||||
async function getAccountId(website: { userId?: string; teamId?: string }): Promise<string | null> {
|
||||
if (website.userId) {
|
||||
return website.userId;
|
||||
async function getAccountId(entity: { userId?: string; teamId?: string }): Promise<string | null> {
|
||||
if (entity.userId) {
|
||||
return entity.userId;
|
||||
}
|
||||
|
||||
if (website.teamId) {
|
||||
if (entity.teamId) {
|
||||
const teamOwner = await prisma.client.teamUser.findFirst({
|
||||
where: {
|
||||
teamId: website.teamId,
|
||||
teamId: entity.teamId,
|
||||
role: ROLES.teamOwner,
|
||||
},
|
||||
select: {
|
||||
@@ -73,22 +73,46 @@ export async function GET(_request: Request, { params }: { params: Promise<{ slu
|
||||
return json(data);
|
||||
}
|
||||
|
||||
const website = await getWebsite(share.entityId);
|
||||
|
||||
if (!website) {
|
||||
return notFound();
|
||||
}
|
||||
|
||||
let entity: { userId?: string; teamId?: string } | null = null;
|
||||
const data: Record<string, any> = {
|
||||
shareId: share.id,
|
||||
shareType: share.shareType,
|
||||
websiteId: share.entityId,
|
||||
parameters: share.parameters,
|
||||
};
|
||||
|
||||
if (share.shareType === ENTITY_TYPE.website) {
|
||||
entity = await getWebsite(share.entityId);
|
||||
|
||||
if (!entity) {
|
||||
return notFound();
|
||||
}
|
||||
|
||||
data.websiteId = share.entityId;
|
||||
} else if (share.shareType === ENTITY_TYPE.pixel) {
|
||||
entity = await getPixel(share.entityId);
|
||||
|
||||
if (!entity) {
|
||||
return notFound();
|
||||
}
|
||||
|
||||
data.websiteId = share.entityId;
|
||||
data.pixelId = share.entityId;
|
||||
} else if (share.shareType === ENTITY_TYPE.link) {
|
||||
entity = await getLink(share.entityId);
|
||||
|
||||
if (!entity) {
|
||||
return notFound();
|
||||
}
|
||||
|
||||
data.websiteId = share.entityId;
|
||||
data.linkId = share.entityId;
|
||||
} else {
|
||||
return notFound();
|
||||
}
|
||||
|
||||
data.token = createToken(data, secret());
|
||||
|
||||
const accountId = await getAccountId(website);
|
||||
const accountId = await getAccountId(entity);
|
||||
|
||||
if (accountId) {
|
||||
const whiteLabel = await getWhiteLabel(accountId);
|
||||
|
||||
@@ -13,6 +13,8 @@ export interface ShareData {
|
||||
websiteId?: string;
|
||||
websiteIds?: string[];
|
||||
boardId?: string;
|
||||
pixelId?: string;
|
||||
linkId?: string;
|
||||
parameters: any;
|
||||
token: string;
|
||||
whiteLabel?: WhiteLabel;
|
||||
@@ -54,12 +56,13 @@ export function ShareProvider({ slug, children }: { slug: string; children: Reac
|
||||
const pathname = usePathname();
|
||||
const path = getSharePath(pathname);
|
||||
const isBoardShare = share?.shareType === ENTITY_TYPE.board;
|
||||
const isWebsiteShare = share?.shareType === ENTITY_TYPE.website;
|
||||
|
||||
const allowedSections = !isBoardShare && share?.parameters
|
||||
const allowedSections = isWebsiteShare && share?.parameters
|
||||
? ALL_SECTION_IDS.filter(id => share.parameters[id] !== false)
|
||||
: [];
|
||||
|
||||
const shouldRedirect = !isBoardShare &&
|
||||
const shouldRedirect = isWebsiteShare &&
|
||||
allowedSections.length === 1 &&
|
||||
allowedSections[0] !== 'overview' &&
|
||||
(path === undefined || path === '' || path === 'overview');
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
import { Column, Grid, Row, useTheme } from '@umami/react-zen';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { LinkPage } from '@/app/(main)/links/[linkId]/LinkPage';
|
||||
import { PixelPage } from '@/app/(main)/pixels/[pixelId]/PixelPage';
|
||||
import { AttributionPage } from '@/app/(main)/websites/[websiteId]/(reports)/attribution/AttributionPage';
|
||||
import { BreakdownPage } from '@/app/(main)/websites/[websiteId]/(reports)/breakdown/BreakdownPage';
|
||||
import { FunnelsPage } from '@/app/(main)/websites/[websiteId]/(reports)/funnels/FunnelsPage';
|
||||
@@ -59,7 +61,7 @@ export function SharePage() {
|
||||
const { setTheme } = useTheme();
|
||||
const pathname = usePathname();
|
||||
const path = getSharePath(pathname);
|
||||
const { websiteId, boardId, parameters = {}, shareType } = share;
|
||||
const { websiteId, boardId, pixelId, linkId, parameters = {}, shareType } = share;
|
||||
|
||||
useEffect(() => {
|
||||
const url = new URL(window?.location?.href);
|
||||
@@ -81,6 +83,14 @@ export function SharePage() {
|
||||
);
|
||||
}
|
||||
|
||||
if (shareType === ENTITY_TYPE.pixel && pixelId) {
|
||||
return <PixelPage pixelId={pixelId} showHeaderActions={false} />;
|
||||
}
|
||||
|
||||
if (shareType === ENTITY_TYPE.link && linkId) {
|
||||
return <LinkPage linkId={linkId} showHeaderActions={false} />;
|
||||
}
|
||||
|
||||
// Check if the requested path is allowed
|
||||
const pageKey = path || '';
|
||||
const isAllowed = pageKey === '' || pageKey === 'overview' || parameters[pageKey] !== false;
|
||||
|
||||
@@ -22,9 +22,11 @@ export * from './queries/useEventDataPropertiesQuery';
|
||||
export * from './queries/useEventDataQuery';
|
||||
export * from './queries/useEventDataValuesQuery';
|
||||
export * from './queries/useLinkQuery';
|
||||
export * from './queries/useLinkSharesQuery';
|
||||
export * from './queries/useLinksQuery';
|
||||
export * from './queries/useLoginQuery';
|
||||
export * from './queries/usePixelQuery';
|
||||
export * from './queries/usePixelSharesQuery';
|
||||
export * from './queries/usePixelsQuery';
|
||||
export * from './queries/useRealtimeQuery';
|
||||
export * from './queries/useReplayQuery';
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { ReactQueryOptions } from '@/lib/types';
|
||||
import { useApi } from '../useApi';
|
||||
import { useModified } from '../useModified';
|
||||
import { usePagedQuery } from '../usePagedQuery';
|
||||
|
||||
export function useLinkSharesQuery(
|
||||
{ linkId }: { linkId: string },
|
||||
options?: ReactQueryOptions,
|
||||
) {
|
||||
const { modified } = useModified('shares');
|
||||
const { get } = useApi();
|
||||
|
||||
return usePagedQuery({
|
||||
queryKey: ['linkShares', { linkId, modified }],
|
||||
queryFn: pageParams => {
|
||||
return get(`/links/${linkId}/shares`, pageParams);
|
||||
},
|
||||
...options,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { ReactQueryOptions } from '@/lib/types';
|
||||
import { useApi } from '../useApi';
|
||||
import { useModified } from '../useModified';
|
||||
import { usePagedQuery } from '../usePagedQuery';
|
||||
|
||||
export function usePixelSharesQuery(
|
||||
{ pixelId }: { pixelId: string },
|
||||
options?: ReactQueryOptions,
|
||||
) {
|
||||
const { modified } = useModified('shares');
|
||||
const { get } = useApi();
|
||||
|
||||
return usePagedQuery({
|
||||
queryKey: ['pixelShares', { pixelId, modified }],
|
||||
queryFn: pageParams => {
|
||||
return get(`/pixels/${pixelId}/shares`, pageParams);
|
||||
},
|
||||
...options,
|
||||
});
|
||||
}
|
||||
@@ -2,7 +2,9 @@ import { useConfig } from '@/components/hooks/useConfig';
|
||||
import { LINKS_URL, PIXELS_URL } from '@/lib/constants';
|
||||
|
||||
export function useSlug(type: 'link' | 'pixel') {
|
||||
const { linksUrl, pixelsUrl } = useConfig();
|
||||
const config = useConfig();
|
||||
const linksUrl = config?.linksUrl;
|
||||
const pixelsUrl = config?.pixelsUrl;
|
||||
|
||||
const hostUrl = type === 'link' ? linksUrl || LINKS_URL : pixelsUrl || PIXELS_URL;
|
||||
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import { Button, Column, Form, FormField, FormSubmitButton, Row, TextField } from '@umami/react-zen';
|
||||
import { useState } from 'react';
|
||||
import { useApi, useMessages, useModified } from '@/components/hooks';
|
||||
|
||||
export function SimpleShareCreateForm({
|
||||
createPath,
|
||||
onSave,
|
||||
onCancel,
|
||||
}: {
|
||||
createPath: string;
|
||||
onSave?: () => void;
|
||||
onCancel?: () => void;
|
||||
}) {
|
||||
const { post } = useApi();
|
||||
const { touch } = useModified();
|
||||
const { t, labels, getErrorMessage } = useMessages();
|
||||
const [isPending, setIsPending] = useState(false);
|
||||
const [error, setError] = useState<unknown>(null);
|
||||
|
||||
const handleSubmit = async (data: { name: string }) => {
|
||||
setIsPending(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
await post(createPath, {
|
||||
name: data.name,
|
||||
});
|
||||
|
||||
touch('shares');
|
||||
onSave?.();
|
||||
} catch (e) {
|
||||
setError(e);
|
||||
} finally {
|
||||
setIsPending(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Form onSubmit={handleSubmit} error={getErrorMessage(error)} defaultValues={{ name: '' }}>
|
||||
<Column gap="4">
|
||||
<FormField label={t(labels.name)} name="name" rules={{ required: t(labels.required) }}>
|
||||
<TextField autoComplete="off" autoFocus />
|
||||
</FormField>
|
||||
<Row justifyContent="flex-end" gap="3">
|
||||
{onCancel && (
|
||||
<Button isDisabled={isPending} onPress={onCancel}>
|
||||
{t(labels.cancel)}
|
||||
</Button>
|
||||
)}
|
||||
<FormSubmitButton variant="primary" isDisabled={isPending}>
|
||||
{t(labels.add)}
|
||||
</FormSubmitButton>
|
||||
</Row>
|
||||
</Column>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { DataColumn, DataTable, type DataTableProps, Row } from '@umami/react-zen';
|
||||
import { ShareDeleteButton } from '@/app/(main)/websites/[websiteId]/settings/ShareDeleteButton';
|
||||
import { CopyButton } from '@/components/common/CopyButton';
|
||||
import { DateDistance } from '@/components/common/DateDistance';
|
||||
import { ExternalLink } from '@/components/common/ExternalLink';
|
||||
import { useConfig, useMessages, useMobile } from '@/components/hooks';
|
||||
|
||||
export function SimpleSharesTable(props: DataTableProps) {
|
||||
const { t, labels } = useMessages();
|
||||
const { cloudMode } = useConfig();
|
||||
const { isMobile } = useMobile();
|
||||
|
||||
const getUrl = (slug: string) => {
|
||||
if (cloudMode) {
|
||||
return `${process.env.cloudUrl}/share/${slug}`;
|
||||
}
|
||||
|
||||
return `${window?.location.origin}${process.env.basePath || ''}/share/${slug}`;
|
||||
};
|
||||
|
||||
return (
|
||||
<DataTable {...props} displayMode={isMobile ? 'cards' : 'table'}>
|
||||
<DataColumn id="name" label={t(labels.name)}>
|
||||
{({ name }: any) => name}
|
||||
</DataColumn>
|
||||
<DataColumn id="slug" label={t(labels.shareUrl)} width="2fr">
|
||||
{({ slug }: any) => {
|
||||
const url = getUrl(slug);
|
||||
|
||||
return (
|
||||
<Row alignItems="center" gap="1" overflow="hidden">
|
||||
<ExternalLink href={url} prefetch={false}>
|
||||
{isMobile ? slug : url}
|
||||
</ExternalLink>
|
||||
<CopyButton value={url} label="Copy URL" />
|
||||
</Row>
|
||||
);
|
||||
}}
|
||||
</DataColumn>
|
||||
<DataColumn id="created" label={t(labels.created)}>
|
||||
{(row: any) => <DateDistance date={new Date(row.createdAt)} />}
|
||||
</DataColumn>
|
||||
<DataColumn id="action" align="end" width="60px">
|
||||
{({ id, slug }: any) => (
|
||||
<Row>
|
||||
<ShareDeleteButton shareId={id} slug={slug} />
|
||||
</Row>
|
||||
)}
|
||||
</DataColumn>
|
||||
</DataTable>
|
||||
);
|
||||
}
|
||||
@@ -23,6 +23,8 @@ export interface Auth {
|
||||
websiteId?: string;
|
||||
websiteIds?: string[];
|
||||
boardId?: string;
|
||||
pixelId?: string;
|
||||
linkId?: string;
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -3,15 +3,19 @@ import { PERMISSIONS } from '@/lib/constants';
|
||||
import type { Auth } from '@/lib/types';
|
||||
import { getLink, getTeamUser } from '@/queries/prisma';
|
||||
|
||||
export async function canViewLink({ user }: Auth, linkId: string) {
|
||||
if (!user) {
|
||||
return false;
|
||||
export async function canViewLink({ user, shareToken }: Auth, linkId: string) {
|
||||
if (user?.isAdmin) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (user.isAdmin) {
|
||||
if (shareToken?.linkId === linkId || shareToken?.websiteId === linkId) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const link = await getLink(linkId);
|
||||
|
||||
if (link.userId) {
|
||||
|
||||
@@ -3,15 +3,19 @@ import { PERMISSIONS } from '@/lib/constants';
|
||||
import type { Auth } from '@/lib/types';
|
||||
import { getPixel, getTeamUser } from '@/queries/prisma';
|
||||
|
||||
export async function canViewPixel({ user }: Auth, pixelId: string) {
|
||||
if (!user) {
|
||||
return false;
|
||||
export async function canViewPixel({ user, shareToken }: Auth, pixelId: string) {
|
||||
if (user?.isAdmin) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (user.isAdmin) {
|
||||
if (shareToken?.pixelId === pixelId || shareToken?.websiteId === pixelId) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const pixel = await getPixel(pixelId);
|
||||
|
||||
if (pixel.userId) {
|
||||
|
||||
@@ -9,7 +9,12 @@ export async function canViewWebsite({ user, shareToken }: Auth, websiteId: stri
|
||||
return true;
|
||||
}
|
||||
|
||||
if (shareToken?.websiteId === websiteId || shareToken?.websiteIds?.includes(websiteId)) {
|
||||
if (
|
||||
shareToken?.websiteId === websiteId ||
|
||||
shareToken?.pixelId === websiteId ||
|
||||
shareToken?.linkId === websiteId ||
|
||||
shareToken?.websiteIds?.includes(websiteId)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user