feat: provider edit preview
This commit is contained in:
parent
45bdf128e9
commit
7d9b99abf3
9 changed files with 405 additions and 9 deletions
73
apps/frontend/src/app/(provider)/layout.tsx
Normal file
73
apps/frontend/src/app/(provider)/layout.tsx
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
export const dynamic = 'force-dynamic';
|
||||
import '../global.scss';
|
||||
import 'react-tooltip/dist/react-tooltip.css';
|
||||
import '@copilotkit/react-ui/styles.css';
|
||||
import LayoutContext from '@gitroom/frontend/components/layout/layout.context';
|
||||
import { ReactNode } from 'react';
|
||||
import { Plus_Jakarta_Sans } from 'next/font/google';
|
||||
import clsx from 'clsx';
|
||||
import { VariableContextComponent } from '@gitroom/react/helpers/variable.context';
|
||||
import UtmSaver from '@gitroom/helpers/utils/utm.saver';
|
||||
|
||||
const jakartaSans = Plus_Jakarta_Sans({
|
||||
weight: ['600', '500'],
|
||||
style: ['normal', 'italic'],
|
||||
subsets: ['latin'],
|
||||
});
|
||||
|
||||
export default async function AppLayout({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<html>
|
||||
<head>
|
||||
<link rel="icon" href="/favicon.ico" sizes="any" />
|
||||
</head>
|
||||
<body
|
||||
className={clsx(jakartaSans.className, 'dark text-primary !bg-primary')}
|
||||
>
|
||||
<VariableContextComponent
|
||||
language="en"
|
||||
storageProvider={
|
||||
process.env.STORAGE_PROVIDER! as 'local' | 'cloudflare'
|
||||
}
|
||||
stripeClient=""
|
||||
environment={process.env.NODE_ENV!}
|
||||
backendUrl={process.env.NEXT_PUBLIC_BACKEND_URL!}
|
||||
plontoKey={process.env.NEXT_PUBLIC_POLOTNO!}
|
||||
billingEnabled={!!process.env.STRIPE_PUBLISHABLE_KEY}
|
||||
discordUrl={process.env.NEXT_PUBLIC_DISCORD_SUPPORT!}
|
||||
frontEndUrl={process.env.FRONTEND_URL!}
|
||||
isGeneral={!!process.env.IS_GENERAL}
|
||||
genericOauth={!!process.env.POSTIZ_GENERIC_OAUTH}
|
||||
oauthLogoUrl={process.env.NEXT_PUBLIC_POSTIZ_OAUTH_LOGO_URL!}
|
||||
oauthDisplayName={process.env.NEXT_PUBLIC_POSTIZ_OAUTH_DISPLAY_NAME!}
|
||||
uploadDirectory={process.env.NEXT_PUBLIC_UPLOAD_STATIC_DIRECTORY!}
|
||||
cloudflareUrl={process.env.CLOUDFLARE_BUCKET_URL || ''}
|
||||
mainUrl={process.env.MAIN_URL || ''}
|
||||
mcpUrl={process.env.MCP_URL}
|
||||
dub={false}
|
||||
facebookPixel={process.env.NEXT_PUBLIC_FACEBOOK_PIXEL!}
|
||||
telegramBotName={process.env.TELEGRAM_BOT_NAME!}
|
||||
neynarClientId={process.env.NEYNAR_CLIENT_ID!}
|
||||
isSecured={!process.env.NOT_SECURED}
|
||||
disableImageCompression={!!process.env.DISABLE_IMAGE_COMPRESSION}
|
||||
disableXAnalytics={!!process.env.DISABLE_X_ANALYTICS}
|
||||
sentryDsn={process.env.NEXT_PUBLIC_SENTRY_DSN!}
|
||||
extensionId={process.env.EXTENSION_ID || ''}
|
||||
transloadit={
|
||||
process.env.TRANSLOADIT_AUTH && process.env.TRANSLOADIT_TEMPLATE
|
||||
? [
|
||||
process.env.TRANSLOADIT_AUTH!,
|
||||
process.env.TRANSLOADIT_TEMPLATE!,
|
||||
]
|
||||
: []
|
||||
}
|
||||
>
|
||||
<LayoutContext>
|
||||
<UtmSaver />
|
||||
{children}
|
||||
</LayoutContext>
|
||||
</VariableContextComponent>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
55
apps/frontend/src/app/(provider)/provider/[p]/bridge.tsx
Normal file
55
apps/frontend/src/app/(provider)/provider/[p]/bridge.tsx
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
'use client';
|
||||
import { FC, useEffect, useRef, useState } from 'react';
|
||||
import {
|
||||
ProviderPreviewComponent,
|
||||
type ProviderPreviewHandle,
|
||||
type ProviderPreviewProps,
|
||||
type ProviderPreviewValidation,
|
||||
} from '@gitroom/frontend/components/provider-preview/preview.provider.component';
|
||||
|
||||
type InitPayload = {
|
||||
value?: Record<string, unknown>;
|
||||
errors?: string[];
|
||||
integration?: ProviderPreviewProps['integration'];
|
||||
};
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__PROVIDER_INIT__?: InitPayload;
|
||||
__getProviderPreviewValues__?: () => Record<string, unknown>;
|
||||
__validateProviderPreview__?: () => Promise<ProviderPreviewValidation>;
|
||||
}
|
||||
}
|
||||
|
||||
export const ProviderPreviewBridge: FC<{ provider: string }> = ({
|
||||
provider,
|
||||
}) => {
|
||||
const [init] = useState<InitPayload>(() =>
|
||||
typeof window !== 'undefined' ? window.__PROVIDER_INIT__ ?? {} : {},
|
||||
);
|
||||
|
||||
const controlRef = useRef<ProviderPreviewHandle | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
window.__getProviderPreviewValues__ = () =>
|
||||
controlRef.current?.getValues() ?? {};
|
||||
window.__validateProviderPreview__ = async () =>
|
||||
controlRef.current
|
||||
? await controlRef.current.validate()
|
||||
: { isValid: false, value: {}, errors: ['not-ready'] };
|
||||
return () => {
|
||||
delete window.__getProviderPreviewValues__;
|
||||
delete window.__validateProviderPreview__;
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<ProviderPreviewComponent
|
||||
provider={provider}
|
||||
value={init.value}
|
||||
errors={init.errors}
|
||||
integration={init.integration}
|
||||
controlRef={controlRef}
|
||||
/>
|
||||
);
|
||||
};
|
||||
52
apps/frontend/src/app/(provider)/provider/[p]/page.tsx
Normal file
52
apps/frontend/src/app/(provider)/provider/[p]/page.tsx
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
/**
|
||||
* Provider settings WebView bridge.
|
||||
*
|
||||
* URL: /provider/:p (e.g. /provider/tiktok, /provider/instagram)
|
||||
*
|
||||
* --- Initial state (native -> WebView, push once) ---
|
||||
* Before loading the URL, the native side injects a global:
|
||||
*
|
||||
* webView.injectJavaScript(`window.__PROVIDER_INIT__ = ${JSON.stringify({
|
||||
* value: { ...currentSettings }, // optional, shape = provider DTO
|
||||
* errors: ['...'], // optional, prior validation errors
|
||||
* integration: { ... }, // optional Partial<Integration>
|
||||
* })};`);
|
||||
*
|
||||
* The bridge reads this once on mount (see ./bridge.tsx).
|
||||
*
|
||||
* --- Reading values & validation (native -> WebView, pull on demand) ---
|
||||
* No messages are posted from the WebView. Instead, native calls these
|
||||
* globals (they are defined once the bridge's effect has run):
|
||||
*
|
||||
* // Returns the current form values, no validation:
|
||||
* webView.evaluateJavaScript('window.__getProviderPreviewValues__()')
|
||||
* // => { ...settings }
|
||||
*
|
||||
* // Triggers validation and returns isValid + flattened error strings:
|
||||
* webView.evaluateJavaScript('window.__validateProviderPreview__()')
|
||||
* // => Promise<{ isValid: boolean, value: {...}, errors: string[] }>
|
||||
*
|
||||
* React Native example (RN WebView ref):
|
||||
* const js = `window.__validateProviderPreview__().then(r =>
|
||||
* window.ReactNativeWebView.postMessage(JSON.stringify(r)));
|
||||
* true;`;
|
||||
* webViewRef.current?.injectJavaScript(js);
|
||||
*
|
||||
* Native should wait for page load (onLoadEnd / didFinishNavigation) before
|
||||
* calling these. If called before the bridge mounts, the validate getter
|
||||
* returns { isValid: false, errors: ['not-ready'] } and the values getter
|
||||
* returns {}.
|
||||
*
|
||||
* If a different channel is needed, adjust ./bridge.tsx — this page is only
|
||||
* a server wrapper that forwards the `:p` route param.
|
||||
*/
|
||||
import { ProviderPreviewBridge } from './bridge';
|
||||
|
||||
export default async function Page({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ p: string }>;
|
||||
}) {
|
||||
const { p } = await params;
|
||||
return <ProviderPreviewBridge provider={p} />;
|
||||
}
|
||||
|
|
@ -4,7 +4,7 @@ import { createContext, useContext } from 'react';
|
|||
import { Integrations } from '@gitroom/frontend/components/launches/calendar.context';
|
||||
import dayjs from 'dayjs';
|
||||
import { newDayjs } from '@gitroom/frontend/components/layout/set.timezone';
|
||||
export const IntegrationContext = createContext<{
|
||||
export type IntegrationContextType = {
|
||||
date: dayjs.Dayjs;
|
||||
integration: Integrations | undefined;
|
||||
allIntegrations: Integrations[];
|
||||
|
|
@ -16,7 +16,8 @@ export const IntegrationContext = createContext<{
|
|||
id: string;
|
||||
}>;
|
||||
}>;
|
||||
}>({
|
||||
};
|
||||
export const IntegrationContext = createContext<IntegrationContextType>({
|
||||
integration: undefined,
|
||||
value: [],
|
||||
date: newDayjs(),
|
||||
|
|
|
|||
|
|
@ -28,11 +28,8 @@ class Empty {
|
|||
empty: string;
|
||||
}
|
||||
|
||||
export enum PostComment {
|
||||
ALL,
|
||||
POST,
|
||||
COMMENT,
|
||||
}
|
||||
export { PostComment } from '@gitroom/frontend/components/new-launch/providers/post-comment.enum';
|
||||
import { PostComment } from '@gitroom/frontend/components/new-launch/providers/post-comment.enum';
|
||||
|
||||
interface CharacterCondition {
|
||||
format: 'no-pictures' | 'with-pictures';
|
||||
|
|
@ -72,7 +69,7 @@ export const withProvider = function <T extends object>(params: {
|
|||
maximumCharacters,
|
||||
} = params;
|
||||
|
||||
return forwardRef((props: { id: string }, ref) => {
|
||||
const Wrapped = forwardRef((props: { id: string }, ref) => {
|
||||
const t = useT();
|
||||
const fetch = useFetch();
|
||||
const {
|
||||
|
|
@ -356,4 +353,30 @@ export const withProvider = function <T extends object>(params: {
|
|||
</IntegrationContext.Provider>
|
||||
);
|
||||
});
|
||||
|
||||
// Expose the settings configuration as static metadata so the preview /
|
||||
// mobile settings page can render <SettingsComponent /> in isolation
|
||||
// without pulling the launch store + DOM portals.
|
||||
(Wrapped as any).__settings = {
|
||||
SettingsComponent,
|
||||
CustomPreviewComponent,
|
||||
dto,
|
||||
postComment,
|
||||
maximumCharacters,
|
||||
};
|
||||
|
||||
return Wrapped;
|
||||
};
|
||||
|
||||
/** Pulls the settings metadata off a withProvider-wrapped component. */
|
||||
export const getProviderSettingsMeta = (component: unknown) => {
|
||||
return (component as any)?.__settings as
|
||||
| {
|
||||
SettingsComponent: FC<{ values?: any }> | null;
|
||||
CustomPreviewComponent?: FC<{ maximumCharacters?: number }>;
|
||||
dto?: any;
|
||||
postComment: PostComment;
|
||||
maximumCharacters?: number | ((settings: any) => number);
|
||||
}
|
||||
| undefined;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -0,0 +1,5 @@
|
|||
export enum PostComment {
|
||||
ALL,
|
||||
POST,
|
||||
COMMENT,
|
||||
}
|
||||
|
|
@ -4,7 +4,7 @@ import { create } from 'zustand';
|
|||
import dayjs from 'dayjs';
|
||||
import { Integrations } from '@gitroom/frontend/components/launches/calendar.context';
|
||||
import { createRef, RefObject } from 'react';
|
||||
import { PostComment } from '@gitroom/frontend/components/new-launch/providers/high.order.provider';
|
||||
import { PostComment } from '@gitroom/frontend/components/new-launch/providers/post-comment.enum';
|
||||
import { newDayjs } from '@gitroom/frontend/components/layout/set.timezone';
|
||||
|
||||
interface Values {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,186 @@
|
|||
'use client';
|
||||
import 'reflect-metadata';
|
||||
import { FC, MutableRefObject, useEffect, useMemo } from 'react';
|
||||
import { FormProvider, useForm, useWatch } from 'react-hook-form';
|
||||
import { classValidatorResolver } from '@hookform/resolvers/class-validator';
|
||||
import { Providers } from '@gitroom/frontend/components/new-launch/providers/show.all.providers';
|
||||
import { getProviderSettingsMeta } from '@gitroom/frontend/components/new-launch/providers/high.order.provider';
|
||||
import {
|
||||
IntegrationContext,
|
||||
type IntegrationContextType,
|
||||
} from '@gitroom/frontend/components/launches/helpers/use.integration';
|
||||
import { newDayjs } from '@gitroom/frontend/components/layout/set.timezone';
|
||||
|
||||
type MockIntegration = IntegrationContextType['integration'];
|
||||
|
||||
export type ProviderPreviewValidation = {
|
||||
isValid: boolean;
|
||||
value: Record<string, unknown>;
|
||||
errors: string[];
|
||||
};
|
||||
|
||||
export type ProviderPreviewHandle = {
|
||||
getValues: () => Record<string, unknown>;
|
||||
validate: () => Promise<ProviderPreviewValidation>;
|
||||
};
|
||||
|
||||
export type ProviderPreviewProps = {
|
||||
/** Provider identifier (e.g. "tiktok", "instagram", "youtube"). */
|
||||
provider: string;
|
||||
/** Initial settings value (shape matches the provider's DTO). */
|
||||
value?: Record<string, unknown>;
|
||||
/**
|
||||
* Called on every form change with the current settings value — for the
|
||||
* mobile WebView bridge this is what you postMessage back.
|
||||
*/
|
||||
onChange?: (value: Record<string, unknown>) => void;
|
||||
/** Validator error messages from a previous failed save, rendered above the form. */
|
||||
errors?: string[];
|
||||
/**
|
||||
* Stub integration to feed the SettingsComponent via IntegrationContext.
|
||||
* Some providers (e.g. TikTok title) branch on `integration.additionalSettings`
|
||||
* or `value[0].image` — pass what you have, leave the rest to defaults.
|
||||
*/
|
||||
integration?: Partial<MockIntegration>;
|
||||
/**
|
||||
* Imperative handle populated on mount. The parent calls
|
||||
* `controlRef.current?.validate()` / `.getValues()` to pull state on demand.
|
||||
*/
|
||||
controlRef?: MutableRefObject<ProviderPreviewHandle | null>;
|
||||
};
|
||||
|
||||
const DEFAULT_INTEGRATION: MockIntegration = {
|
||||
id: 'preview',
|
||||
name: 'Preview',
|
||||
identifier: '',
|
||||
picture: '',
|
||||
display: '',
|
||||
type: 'social',
|
||||
editor: 'normal' as const,
|
||||
disabled: false,
|
||||
inBetweenSteps: false,
|
||||
additionalSettings: '[]',
|
||||
changeProfilePicture: false,
|
||||
changeNickName: false,
|
||||
time: [] as { time: number }[],
|
||||
};
|
||||
|
||||
/** Emits onChange whenever the form changes. Mounted inside FormProvider. */
|
||||
const FormChangeEmitter: FC<{
|
||||
onChange?: (value: Record<string, unknown>) => void;
|
||||
}> = ({ onChange }) => {
|
||||
const values = useWatch();
|
||||
useEffect(() => {
|
||||
if (onChange) onChange(values ?? {});
|
||||
}, [values, onChange]);
|
||||
return null;
|
||||
};
|
||||
|
||||
const flattenFormErrors = (errs: unknown): string[] => {
|
||||
const out: string[] = [];
|
||||
const walk = (node: unknown) => {
|
||||
if (!node || typeof node !== 'object') return;
|
||||
const n = node as Record<string, unknown>;
|
||||
if (typeof n.message === 'string') out.push(n.message);
|
||||
if (n.types && typeof n.types === 'object') {
|
||||
for (const t of Object.values(n.types as Record<string, unknown>)) {
|
||||
if (typeof t === 'string') out.push(t);
|
||||
}
|
||||
}
|
||||
for (const [key, child] of Object.entries(n)) {
|
||||
if (['message', 'type', 'types', 'ref', 'root'].includes(key)) continue;
|
||||
walk(child);
|
||||
}
|
||||
};
|
||||
walk(errs);
|
||||
return out;
|
||||
};
|
||||
|
||||
export const ProviderPreviewComponent: FC<ProviderPreviewProps> = ({
|
||||
provider,
|
||||
value,
|
||||
onChange,
|
||||
errors,
|
||||
integration,
|
||||
controlRef,
|
||||
}) => {
|
||||
const meta = useMemo(() => {
|
||||
const entry = Providers.find((p) => p.identifier === provider);
|
||||
if (!entry) return null;
|
||||
return getProviderSettingsMeta(entry.component);
|
||||
}, [provider]);
|
||||
|
||||
const form = useForm({
|
||||
resolver: meta?.dto ? classValidatorResolver(meta.dto) : undefined,
|
||||
defaultValues: value ?? {},
|
||||
values: value,
|
||||
mode: 'all',
|
||||
criteriaMode: 'all',
|
||||
reValidateMode: 'onChange',
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!controlRef) return;
|
||||
controlRef.current = {
|
||||
getValues: () => form.getValues() as Record<string, unknown>,
|
||||
validate: async () => {
|
||||
const isValid = await form.trigger(undefined, { shouldFocus: false });
|
||||
return {
|
||||
isValid,
|
||||
value: form.getValues() as Record<string, unknown>,
|
||||
errors: flattenFormErrors(form.formState.errors),
|
||||
};
|
||||
},
|
||||
};
|
||||
return () => {
|
||||
if (controlRef.current) controlRef.current = null;
|
||||
};
|
||||
}, [controlRef, form]);
|
||||
|
||||
const contextValue = useMemo<IntegrationContextType>(
|
||||
() => ({
|
||||
date: newDayjs(),
|
||||
integration: {
|
||||
...(DEFAULT_INTEGRATION as MockIntegration),
|
||||
identifier: provider,
|
||||
...integration,
|
||||
} as MockIntegration,
|
||||
allIntegrations: [],
|
||||
value: [],
|
||||
}),
|
||||
[provider, integration],
|
||||
);
|
||||
|
||||
if (!meta) {
|
||||
return <div>Provider "{provider}" not found</div>;
|
||||
}
|
||||
|
||||
const { SettingsComponent } = meta;
|
||||
if (!SettingsComponent) {
|
||||
return (
|
||||
<div className="p-4 text-sm">
|
||||
This provider has no configurable settings.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<IntegrationContext.Provider value={contextValue}>
|
||||
<FormProvider {...form}>
|
||||
<div className="flex flex-col gap-4 text-white">
|
||||
{errors && errors.length > 0 && (
|
||||
<div className="rounded-md border border-red-500/40 bg-red-500/10 p-3 text-sm text-red-300">
|
||||
<ul className="list-disc ps-5">
|
||||
{errors.map((e, i) => (
|
||||
<li key={i}>{e}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
<FormChangeEmitter onChange={onChange} />
|
||||
<SettingsComponent />
|
||||
</div>
|
||||
</FormProvider>
|
||||
</IntegrationContext.Provider>
|
||||
);
|
||||
};
|
||||
|
|
@ -46,6 +46,7 @@ export async function proxy(request: NextRequest) {
|
|||
if (
|
||||
nextUrl.pathname.startsWith('/uploads/') ||
|
||||
nextUrl.pathname.startsWith('/p/') ||
|
||||
nextUrl.pathname.startsWith('/provider/') ||
|
||||
nextUrl.pathname.startsWith('/icons/')
|
||||
) {
|
||||
return topResponse;
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue