feat: post missing modal
This commit is contained in:
parent
cfa52b2336
commit
613a4285ff
9 changed files with 359 additions and 29 deletions
|
|
@ -45,6 +45,23 @@ export class PostsController {
|
|||
return this._postsService.getStatistics(org.id, id);
|
||||
}
|
||||
|
||||
@Get('/:id/missing')
|
||||
async getMissingContent(
|
||||
@GetOrgFromRequest() org: Organization,
|
||||
@Param('id') id: string
|
||||
) {
|
||||
return this._postsService.getMissingContent(org.id, id);
|
||||
}
|
||||
|
||||
@Put('/:id/release-id')
|
||||
async updateReleaseId(
|
||||
@GetOrgFromRequest() org: Organization,
|
||||
@Param('id') id: string,
|
||||
@Body('releaseId') releaseId: string
|
||||
) {
|
||||
return this._postsService.updateReleaseId(org.id, id, releaseId);
|
||||
}
|
||||
|
||||
@Post('/should-shortlink')
|
||||
async shouldShortlink(@Body() body: { messages: string[] }) {
|
||||
return { ask: this._shortLinkService.askShortLinkedin(body.messages) };
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import {
|
|||
HttpException,
|
||||
Param,
|
||||
Post,
|
||||
Put,
|
||||
Query,
|
||||
UploadedFile,
|
||||
UseInterceptors,
|
||||
|
|
@ -268,6 +269,25 @@ export class PublicIntegrationsController {
|
|||
};
|
||||
}
|
||||
|
||||
@Get('/posts/:id/missing')
|
||||
async getMissingContent(
|
||||
@GetOrgFromRequest() org: Organization,
|
||||
@Param('id') id: string
|
||||
) {
|
||||
Sentry.metrics.count('public_api-request', 1);
|
||||
return this._postsService.getMissingContent(org.id, id);
|
||||
}
|
||||
|
||||
@Put('/posts/:id/release-id')
|
||||
async updateReleaseId(
|
||||
@GetOrgFromRequest() org: Organization,
|
||||
@Param('id') id: string,
|
||||
@Body('releaseId') releaseId: string
|
||||
) {
|
||||
Sentry.metrics.count('public_api-request', 1);
|
||||
return this._postsService.updateReleaseId(org.id, id, releaseId);
|
||||
}
|
||||
|
||||
@Get('/analytics/:integration')
|
||||
async getAnalytics(
|
||||
@GetOrgFromRequest() org: Organization,
|
||||
|
|
|
|||
|
|
@ -47,6 +47,7 @@ import { extend } from 'dayjs';
|
|||
import { isUSCitizen } from './helpers/isuscitizen.utils';
|
||||
import { useInterval } from '@mantine/hooks';
|
||||
import { StatisticsModal } from '@gitroom/frontend/components/launches/statistics';
|
||||
import { MissingReleaseModal } from '@gitroom/frontend/components/launches/missing-release.modal';
|
||||
import { useT } from '@gitroom/react/translation/get.transation.service.client';
|
||||
import i18next from 'i18next';
|
||||
import { AddEditModal } from '@gitroom/frontend/components/new-launch/add.edit.modal';
|
||||
|
|
@ -214,7 +215,26 @@ const usePostActions = (onMutate?: () => void) => {
|
|||
[modal, t]
|
||||
);
|
||||
|
||||
return { editPost, deletePost, openStatistics };
|
||||
const openMissingRelease = useCallback(
|
||||
(id: string) => () => {
|
||||
modal.openModal({
|
||||
title: t('connect_post', 'Connect Post'),
|
||||
closeOnClickOutside: true,
|
||||
closeOnEscape: true,
|
||||
withCloseButton: true,
|
||||
classNames: {
|
||||
modal: 'w-[100%] max-w-[800px]',
|
||||
},
|
||||
children: (
|
||||
<MissingReleaseModal postId={id} onSuccess={mutate} />
|
||||
),
|
||||
size: '60%',
|
||||
});
|
||||
},
|
||||
[modal, t, mutate]
|
||||
);
|
||||
|
||||
return { editPost, deletePost, openStatistics, openMissingRelease };
|
||||
};
|
||||
|
||||
export const DayView = () => {
|
||||
|
|
@ -452,7 +472,7 @@ export const ListView = () => {
|
|||
const { integrations, loading, listPosts } = useCalendar();
|
||||
|
||||
// Use shared post actions hook
|
||||
const { editPost, deletePost, openStatistics } = usePostActions();
|
||||
const { editPost, deletePost, openStatistics, openMissingRelease } = usePostActions();
|
||||
|
||||
// Group posts by date
|
||||
const groupedPosts = useMemo(() => {
|
||||
|
|
@ -502,6 +522,7 @@ export const ListView = () => {
|
|||
date={newDayjs(post.publishDate)}
|
||||
state={post.state}
|
||||
statistics={openStatistics(post.id)}
|
||||
missingRelease={openMissingRelease(post.id)}
|
||||
editPost={editPost(post, false)}
|
||||
duplicatePost={editPost(post, true)}
|
||||
post={post}
|
||||
|
|
@ -557,7 +578,7 @@ export const CalendarColumn: FC<{
|
|||
const fetch = useFetch();
|
||||
|
||||
// Use shared post actions hook
|
||||
const { editPost, deletePost, openStatistics } = usePostActions();
|
||||
const { editPost, deletePost, openStatistics, openMissingRelease } = usePostActions();
|
||||
const postList = useMemo(() => {
|
||||
return posts.filter((post) => {
|
||||
const pList = dayjs.utc(post.publishDate).local();
|
||||
|
|
@ -820,6 +841,7 @@ export const CalendarColumn: FC<{
|
|||
date={getDate}
|
||||
state={post.state}
|
||||
statistics={openStatistics(post.id)}
|
||||
missingRelease={openMissingRelease(post.id)}
|
||||
editPost={editPost(post, false)}
|
||||
duplicatePost={editPost(post, true)}
|
||||
post={post}
|
||||
|
|
@ -929,6 +951,7 @@ const CalendarItem: FC<{
|
|||
duplicatePost: () => void;
|
||||
deletePost: () => void;
|
||||
statistics: () => void;
|
||||
missingRelease?: () => void;
|
||||
integrations: Integrations[];
|
||||
state: State;
|
||||
display: 'day' | 'week' | 'month';
|
||||
|
|
@ -952,6 +975,7 @@ const CalendarItem: FC<{
|
|||
display,
|
||||
deletePost,
|
||||
showTime,
|
||||
missingRelease,
|
||||
} = props;
|
||||
const { disableXAnalytics } = useVariables();
|
||||
const preview = useCallback(() => {
|
||||
|
|
@ -1016,7 +1040,17 @@ const CalendarItem: FC<{
|
|||
</div>{' '}
|
||||
{((post.integration.providerIdentifier === 'x' && disableXAnalytics) || !post.releaseId) ? (
|
||||
<></>
|
||||
) : (
|
||||
) : post.releaseId === 'missing' && missingRelease ? (
|
||||
<div
|
||||
className={clsx(
|
||||
'hidden group-hover:block hover:underline cursor-pointer',
|
||||
post?.tags?.[0]?.tag?.color && 'mix-blend-difference'
|
||||
)}
|
||||
onClick={missingRelease}
|
||||
>
|
||||
<Statistics />
|
||||
</div>
|
||||
) : post.releaseId !== 'missing' ? (
|
||||
<div
|
||||
className={clsx(
|
||||
'hidden group-hover:block hover:underline cursor-pointer',
|
||||
|
|
@ -1026,6 +1060,8 @@ const CalendarItem: FC<{
|
|||
>
|
||||
<Statistics />
|
||||
</div>
|
||||
) : (
|
||||
<></>
|
||||
)}{' '}
|
||||
<div
|
||||
className={clsx(
|
||||
|
|
|
|||
129
apps/frontend/src/components/launches/missing-release.modal.tsx
Normal file
129
apps/frontend/src/components/launches/missing-release.modal.tsx
Normal file
|
|
@ -0,0 +1,129 @@
|
|||
'use client';
|
||||
|
||||
import React, { FC, useCallback, useState } from 'react';
|
||||
import useSWR from 'swr';
|
||||
import { useFetch } from '@gitroom/helpers/utils/custom.fetch';
|
||||
import { useT } from '@gitroom/react/translation/get.transation.service.client';
|
||||
import { useModals } from '@gitroom/frontend/components/layout/new-modal';
|
||||
import { LoadingComponent } from '@gitroom/frontend/components/layout/loading';
|
||||
import { useToaster } from '@gitroom/react/toaster/toaster';
|
||||
import { Button } from '@gitroom/react/form/button';
|
||||
import { StatisticsModal } from '@gitroom/frontend/components/launches/statistics';
|
||||
|
||||
export const MissingReleaseModal: FC<{
|
||||
postId: string;
|
||||
onSuccess: () => void;
|
||||
}> = ({ postId, onSuccess }) => {
|
||||
const t = useT();
|
||||
const fetch = useFetch();
|
||||
const modal = useModals();
|
||||
const toaster = useToaster();
|
||||
const [selected, setSelected] = useState<string | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const loadMissingContent = useCallback(async () => {
|
||||
return (await fetch(`/posts/${postId}/missing`)).json();
|
||||
}, [postId, fetch]);
|
||||
|
||||
const { data, isLoading } = useSWR(
|
||||
`/posts/${postId}/missing`,
|
||||
loadMissingContent
|
||||
);
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
if (!selected) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
await fetch(`/posts/${postId}/release-id`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ releaseId: selected }),
|
||||
});
|
||||
onSuccess();
|
||||
modal.closeAll();
|
||||
modal.openModal({
|
||||
title: t('statistics', 'Statistics'),
|
||||
closeOnClickOutside: true,
|
||||
closeOnEscape: true,
|
||||
withCloseButton: true,
|
||||
classNames: {
|
||||
modal: 'w-[100%] max-w-[1400px]',
|
||||
},
|
||||
children: <StatisticsModal postId={postId} />,
|
||||
size: '80%',
|
||||
});
|
||||
} catch {
|
||||
toaster.show(
|
||||
t('release_id_update_failed', 'Failed to connect post'),
|
||||
'warning'
|
||||
);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}, [selected, postId, fetch, toaster, t, onSuccess, modal]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-[40px]">
|
||||
<LoadingComponent />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!data || data.length === 0) {
|
||||
return (
|
||||
<div className="text-center text-textColor py-[20px]">
|
||||
{t(
|
||||
'no_missing_content',
|
||||
'No content found from this provider. The provider may not support this feature.'
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-[16px]">
|
||||
<div className="text-[14px] text-textColor/70">
|
||||
{t(
|
||||
'select_matching_content',
|
||||
'Select the content that matches this post:'
|
||||
)}
|
||||
</div>
|
||||
<div className="grid grid-cols-3 sm:grid-cols-4 lg:grid-cols-5 gap-[10px] max-h-[400px] overflow-y-auto scrollbar scrollbar-thumb-fifth scrollbar-track-newBgColor p-[4px]">
|
||||
{data.map((item: { id: string; url: string }) => (
|
||||
<div
|
||||
key={item.id}
|
||||
onClick={() => setSelected(item.id)}
|
||||
className={`cursor-pointer rounded-[8px] overflow-hidden border-2 transition-all ${
|
||||
selected === item.id
|
||||
? 'border-[#612BD3] scale-[1.02]'
|
||||
: 'border-transparent hover:border-textColor/20'
|
||||
}`}
|
||||
>
|
||||
<img
|
||||
src={item.url}
|
||||
alt={item.id}
|
||||
className="w-full aspect-square object-cover"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex justify-end gap-[10px] pt-[8px] border-t border-tableBorder">
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => modal.closeAll()}
|
||||
className="bg-transparent border border-tableBorder text-textColor"
|
||||
>
|
||||
{t('cancel', 'Cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleSave}
|
||||
disabled={!selected || saving}
|
||||
loading={saving}
|
||||
>
|
||||
{t('connect_post', 'Connect Post')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -1,10 +1,11 @@
|
|||
import React, { FC, Fragment, useCallback, useMemo, useState } from 'react';
|
||||
import useSWR from 'swr';
|
||||
import useSWR, { useSWRConfig } from 'swr';
|
||||
import { useFetch } from '@gitroom/helpers/utils/custom.fetch';
|
||||
import { useT } from '@gitroom/react/translation/get.transation.service.client';
|
||||
import { ChartSocial } from '@gitroom/frontend/components/analytics/chart-social';
|
||||
import { Select } from '@gitroom/react/form/select';
|
||||
import { LoadingComponent } from '@gitroom/frontend/components/layout/loading';
|
||||
import { MissingReleaseModal } from '@gitroom/frontend/components/launches/missing-release.modal';
|
||||
|
||||
interface AnalyticsData {
|
||||
label: string;
|
||||
|
|
@ -34,7 +35,7 @@ export const StatisticsModal: FC<{
|
|||
loadStatistics
|
||||
);
|
||||
|
||||
const { data: analyticsData, isLoading: isLoadingAnalytics } = useSWR(
|
||||
const { data: analyticsData, isLoading: isLoadingAnalytics, mutate: mutateAnalytics } = useSWR(
|
||||
`/analytics/post/${postId}?date=${dateRange}`,
|
||||
loadPostAnalytics,
|
||||
{
|
||||
|
|
@ -47,6 +48,8 @@ export const StatisticsModal: FC<{
|
|||
}
|
||||
);
|
||||
|
||||
const isMissing = analyticsData && !Array.isArray(analyticsData) && analyticsData.missing;
|
||||
|
||||
const dateOptions = useMemo(() => {
|
||||
return [
|
||||
{ key: 7, value: t('7_days', '7 Days') },
|
||||
|
|
@ -76,6 +79,8 @@ export const StatisticsModal: FC<{
|
|||
<div className="flex items-center justify-center py-[40px]">
|
||||
<LoadingComponent />
|
||||
</div>
|
||||
) : isMissing ? (
|
||||
<MissingReleaseModal postId={postId} onSuccess={() => mutateAnalytics()} />
|
||||
) : (
|
||||
<div className="flex flex-col gap-[24px]">
|
||||
{/* Post Analytics Section */}
|
||||
|
|
|
|||
|
|
@ -372,6 +372,19 @@ export class PostsRepository {
|
|||
});
|
||||
}
|
||||
|
||||
updateReleaseId(id: string, orgId: string, releaseId: string) {
|
||||
return this._post.model.post.update({
|
||||
where: {
|
||||
id,
|
||||
organizationId: orgId,
|
||||
releaseId: 'missing',
|
||||
},
|
||||
data: {
|
||||
releaseId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async changeState(id: string, state: State, err?: any, body?: any) {
|
||||
const update = await this._post.model.post.update({
|
||||
where: {
|
||||
|
|
|
|||
|
|
@ -64,17 +64,85 @@ export class PostsService {
|
|||
return this._postRepository.updatePost(id, postId, releaseURL);
|
||||
}
|
||||
|
||||
async getMissingContent(
|
||||
orgId: string,
|
||||
postId: string,
|
||||
forceRefresh = false
|
||||
): Promise<{ id: string; url: string }[]> {
|
||||
const post = await this._postRepository.getPostById(postId, orgId);
|
||||
if (!post || post.releaseId !== 'missing') {
|
||||
return [];
|
||||
}
|
||||
|
||||
const integrationProvider = this._integrationManager.getSocialIntegration(
|
||||
post.integration.providerIdentifier
|
||||
);
|
||||
|
||||
if (!integrationProvider.missing) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const getIntegration = post.integration!;
|
||||
|
||||
if (
|
||||
dayjs(getIntegration?.tokenExpiration).isBefore(dayjs()) ||
|
||||
forceRefresh
|
||||
) {
|
||||
const data = await this._refreshIntegrationService.refresh(
|
||||
getIntegration
|
||||
);
|
||||
if (!data) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const { accessToken } = data;
|
||||
|
||||
if (accessToken) {
|
||||
getIntegration.token = accessToken;
|
||||
|
||||
if (integrationProvider.refreshWait) {
|
||||
await timer(10000);
|
||||
}
|
||||
} else {
|
||||
await this._integrationService.disconnectChannel(orgId, getIntegration);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
return await integrationProvider.missing(
|
||||
getIntegration.internalId,
|
||||
getIntegration.token
|
||||
);
|
||||
} catch (e) {
|
||||
console.log(e);
|
||||
if (e instanceof RefreshToken) {
|
||||
return this.getMissingContent(orgId, postId, true);
|
||||
}
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
async updateReleaseId(orgId: string, postId: string, releaseId: string) {
|
||||
return this._postRepository.updateReleaseId(postId, orgId, releaseId);
|
||||
}
|
||||
|
||||
async checkPostAnalytics(
|
||||
orgId: string,
|
||||
postId: string,
|
||||
date: number,
|
||||
forceRefresh = false
|
||||
): Promise<AnalyticsData[]> {
|
||||
): Promise<AnalyticsData[] | { missing: true }> {
|
||||
const post = await this._postRepository.getPostById(postId, orgId);
|
||||
if (!post || !post.releaseId) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (post.releaseId === 'missing') {
|
||||
return { missing: true };
|
||||
}
|
||||
|
||||
const integrationProvider = this._integrationManager.getSocialIntegration(
|
||||
post.integration.providerIdentifier
|
||||
);
|
||||
|
|
|
|||
|
|
@ -44,6 +44,10 @@ export interface IAuthenticator {
|
|||
accessToken: string,
|
||||
url: string
|
||||
): Promise<{ url: string }>;
|
||||
missing?(
|
||||
id: string,
|
||||
accessToken: string
|
||||
): Promise<{ id: string; url: string }[]>;
|
||||
}
|
||||
|
||||
export interface AnalyticsData {
|
||||
|
|
|
|||
|
|
@ -369,7 +369,7 @@ export class TiktokProvider extends SocialAbstract implements SocialProvider {
|
|||
id: string,
|
||||
publishId: string,
|
||||
accessToken: string
|
||||
): Promise<{ url: string; id: number }> {
|
||||
): Promise<{ url: string; id: string }> {
|
||||
// eslint-disable-next-line no-constant-condition
|
||||
while (true) {
|
||||
const post = await (
|
||||
|
|
@ -396,7 +396,7 @@ export class TiktokProvider extends SocialAbstract implements SocialProvider {
|
|||
if (status === 'SEND_TO_USER_INBOX') {
|
||||
return {
|
||||
url: 'https://www.tiktok.com/messages?lang=en',
|
||||
id: Math.floor(Math.random() * 1000000 + 100000),
|
||||
id: 'missing',
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -703,6 +703,40 @@ export class TiktokProvider extends SocialAbstract implements SocialProvider {
|
|||
}
|
||||
}
|
||||
|
||||
async missing(
|
||||
id: string,
|
||||
accessToken: string
|
||||
): Promise<{ id: string; url: string }[]> {
|
||||
try {
|
||||
const videoListResponse = await this.fetch(
|
||||
'https://open.tiktokapis.com/v2/video/list/?fields=id,cover_image_url,title',
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
body: JSON.stringify({ max_count: 20 }),
|
||||
}
|
||||
);
|
||||
|
||||
const videoListData = await videoListResponse.json();
|
||||
const videos = videoListData?.data?.videos;
|
||||
|
||||
if (!videos || videos.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return videos.map((v: { id: string; cover_image_url: string }) => ({
|
||||
id: String(v.id),
|
||||
url: v.cover_image_url,
|
||||
}));
|
||||
} catch (err) {
|
||||
console.error('Error fetching TikTok missing content:', err);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async postAnalytics(
|
||||
integrationId: string,
|
||||
accessToken: string,
|
||||
|
|
@ -711,27 +745,31 @@ export class TiktokProvider extends SocialAbstract implements SocialProvider {
|
|||
): Promise<AnalyticsData[]> {
|
||||
const today = dayjs().format('YYYY-MM-DD');
|
||||
|
||||
const post = await (
|
||||
await this.fetch(
|
||||
'https://open.tiktokapis.com/v2/post/publish/status/fetch/',
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json; charset=UTF-8',
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
if (postId.indexOf('v_pub_url') > -1) {
|
||||
const post = await (
|
||||
await this.fetch(
|
||||
'https://open.tiktokapis.com/v2/post/publish/status/fetch/',
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json; charset=UTF-8',
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
publish_id: postId,
|
||||
}),
|
||||
},
|
||||
body: JSON.stringify({
|
||||
publish_id: postId,
|
||||
}),
|
||||
},
|
||||
'',
|
||||
0,
|
||||
true
|
||||
)
|
||||
).json();
|
||||
'',
|
||||
0,
|
||||
true
|
||||
)
|
||||
).json();
|
||||
|
||||
if (!post?.data?.publicaly_available_post_id?.[0]) {
|
||||
return [];
|
||||
if (!post?.data?.publicaly_available_post_id?.[0]) {
|
||||
return [];
|
||||
}
|
||||
|
||||
postId = post.data.publicaly_available_post_id[0];
|
||||
}
|
||||
|
||||
try {
|
||||
|
|
@ -746,7 +784,7 @@ export class TiktokProvider extends SocialAbstract implements SocialProvider {
|
|||
},
|
||||
body: JSON.stringify({
|
||||
filters: {
|
||||
video_ids: post?.data?.publicaly_available_post_id.map(String),
|
||||
video_ids: [postId],
|
||||
},
|
||||
}),
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue