diff --git a/apps/frontend/src/app/(provider)/layout.tsx b/apps/frontend/src/app/(provider)/layout.tsx new file mode 100644 index 00000000..f711e7de --- /dev/null +++ b/apps/frontend/src/app/(provider)/layout.tsx @@ -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 ( + + + + + + + + + {children} + + + + + ); +} diff --git a/apps/frontend/src/app/(provider)/provider/[p]/bridge.tsx b/apps/frontend/src/app/(provider)/provider/[p]/bridge.tsx new file mode 100644 index 00000000..8b713bb9 --- /dev/null +++ b/apps/frontend/src/app/(provider)/provider/[p]/bridge.tsx @@ -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; + errors?: string[]; + integration?: ProviderPreviewProps['integration']; +}; + +declare global { + interface Window { + __PROVIDER_INIT__?: InitPayload; + __getProviderPreviewValues__?: () => Record; + __validateProviderPreview__?: () => Promise; + } +} + +export const ProviderPreviewBridge: FC<{ provider: string }> = ({ + provider, +}) => { + const [init] = useState(() => + typeof window !== 'undefined' ? window.__PROVIDER_INIT__ ?? {} : {}, + ); + + const controlRef = useRef(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 ( + + ); +}; diff --git a/apps/frontend/src/app/(provider)/provider/[p]/page.tsx b/apps/frontend/src/app/(provider)/provider/[p]/page.tsx new file mode 100644 index 00000000..15a51d7b --- /dev/null +++ b/apps/frontend/src/app/(provider)/provider/[p]/page.tsx @@ -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 + * })};`); + * + * 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 ; +} diff --git a/apps/frontend/src/components/launches/helpers/use.integration.ts b/apps/frontend/src/components/launches/helpers/use.integration.ts index 265431f5..ef53c0ac 100644 --- a/apps/frontend/src/components/launches/helpers/use.integration.ts +++ b/apps/frontend/src/components/launches/helpers/use.integration.ts @@ -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({ integration: undefined, value: [], date: newDayjs(), diff --git a/apps/frontend/src/components/new-launch/providers/high.order.provider.tsx b/apps/frontend/src/components/new-launch/providers/high.order.provider.tsx index 4fdbde4e..b66c76b6 100644 --- a/apps/frontend/src/components/new-launch/providers/high.order.provider.tsx +++ b/apps/frontend/src/components/new-launch/providers/high.order.provider.tsx @@ -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 (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 (params: { ); }); + + // Expose the settings configuration as static metadata so the preview / + // mobile settings page can render 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; }; diff --git a/apps/frontend/src/components/new-launch/providers/post-comment.enum.ts b/apps/frontend/src/components/new-launch/providers/post-comment.enum.ts new file mode 100644 index 00000000..bfaee947 --- /dev/null +++ b/apps/frontend/src/components/new-launch/providers/post-comment.enum.ts @@ -0,0 +1,5 @@ +export enum PostComment { + ALL, + POST, + COMMENT, +} diff --git a/apps/frontend/src/components/new-launch/store.ts b/apps/frontend/src/components/new-launch/store.ts index f848bc60..2ea620b8 100644 --- a/apps/frontend/src/components/new-launch/store.ts +++ b/apps/frontend/src/components/new-launch/store.ts @@ -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 { diff --git a/apps/frontend/src/components/provider-preview/preview.provider.component.tsx b/apps/frontend/src/components/provider-preview/preview.provider.component.tsx new file mode 100644 index 00000000..9c84d8ce --- /dev/null +++ b/apps/frontend/src/components/provider-preview/preview.provider.component.tsx @@ -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; + errors: string[]; +}; + +export type ProviderPreviewHandle = { + getValues: () => Record; + validate: () => Promise; +}; + +export type ProviderPreviewProps = { + /** Provider identifier (e.g. "tiktok", "instagram", "youtube"). */ + provider: string; + /** Initial settings value (shape matches the provider's DTO). */ + value?: Record; + /** + * Called on every form change with the current settings value — for the + * mobile WebView bridge this is what you postMessage back. + */ + onChange?: (value: Record) => 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; + /** + * Imperative handle populated on mount. The parent calls + * `controlRef.current?.validate()` / `.getValues()` to pull state on demand. + */ + controlRef?: MutableRefObject; +}; + +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) => 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; + 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)) { + 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 = ({ + 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, + validate: async () => { + const isValid = await form.trigger(undefined, { shouldFocus: false }); + return { + isValid, + value: form.getValues() as Record, + errors: flattenFormErrors(form.formState.errors), + }; + }, + }; + return () => { + if (controlRef.current) controlRef.current = null; + }; + }, [controlRef, form]); + + const contextValue = useMemo( + () => ({ + date: newDayjs(), + integration: { + ...(DEFAULT_INTEGRATION as MockIntegration), + identifier: provider, + ...integration, + } as MockIntegration, + allIntegrations: [], + value: [], + }), + [provider, integration], + ); + + if (!meta) { + return
Provider "{provider}" not found
; + } + + const { SettingsComponent } = meta; + if (!SettingsComponent) { + return ( +
+ This provider has no configurable settings. +
+ ); + } + + return ( + + +
+ {errors && errors.length > 0 && ( +
+
    + {errors.map((e, i) => ( +
  • {e}
  • + ))} +
+
+ )} + + +
+
+
+ ); +}; diff --git a/apps/frontend/src/proxy.ts b/apps/frontend/src/proxy.ts index da712ea5..360c937e 100644 --- a/apps/frontend/src/proxy.ts +++ b/apps/frontend/src/proxy.ts @@ -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;