diff --git a/assets/images/clickatell-logo.png b/assets/images/gateways/clickatell-logo.png similarity index 100% rename from assets/images/clickatell-logo.png rename to assets/images/gateways/clickatell-logo.png diff --git a/assets/images/clickatell.svg b/assets/images/gateways/clickatell.svg similarity index 100% rename from assets/images/clickatell.svg rename to assets/images/gateways/clickatell.svg diff --git a/assets/images/gateways/common-sms.png b/assets/images/gateways/common-sms.png new file mode 100644 index 0000000..e1b4e4e Binary files /dev/null and b/assets/images/gateways/common-sms.png differ diff --git a/assets/images/plivo-logo.png b/assets/images/gateways/plivo-logo.png similarity index 100% rename from assets/images/plivo-logo.png rename to assets/images/gateways/plivo-logo.png diff --git a/assets/images/plivo.svg b/assets/images/gateways/plivo.svg similarity index 100% rename from assets/images/plivo.svg rename to assets/images/gateways/plivo.svg diff --git a/assets/images/twilio-logo.png b/assets/images/gateways/twilio-logo.png similarity index 100% rename from assets/images/twilio-logo.png rename to assets/images/gateways/twilio-logo.png diff --git a/assets/images/twilio.svg b/assets/images/gateways/twilio.svg similarity index 100% rename from assets/images/twilio.svg rename to assets/images/gateways/twilio.svg diff --git a/assets/images/vonage-logo.png b/assets/images/gateways/vonage-logo.png similarity index 100% rename from assets/images/vonage-logo.png rename to assets/images/gateways/vonage-logo.png diff --git a/assets/images/vonage.svg b/assets/images/gateways/vonage.svg similarity index 100% rename from assets/images/vonage.svg rename to assets/images/gateways/vonage.svg diff --git a/includes/Admin/Menu.php b/includes/Admin/Menu.php index 8187561..2d35aff 100644 --- a/includes/Admin/Menu.php +++ b/includes/Admin/Menu.php @@ -101,6 +101,11 @@ public function render_page() { public function enqueue_scripts() { $asset_file = include TEXTY_DIR . '/dist/index.asset.php'; + // The reusable `texty-components` bundle (window.texty.components) is + // registered globally by Texty\Assets so add-ons can depend on it on + // both admin and storefront. On this page it is pulled in on demand by + // whichever add-on script declares it as a dependency. + wp_register_script( 'texty-admin', TEXTY_URL . '/dist/index.js', diff --git a/includes/Assets.php b/includes/Assets.php new file mode 100644 index 0000000..46993f6 --- /dev/null +++ b/includes/Assets.php @@ -0,0 +1,83 @@ +_message`, `_recipients`. -const SEP = '_'; - // Maps the backend `icon` string on the page element to a lucide component. const ICON_MAP: Record> = { Globe, ShoppingCart, Store, Bell, + Zap, }; const assetUrl: string = window.texty?.asset_url ?? ''; @@ -38,22 +37,29 @@ type SchemaResponse = { values: Record; }; -type SavePayloadEntry = { - enabled: boolean; - message: string; - recipients?: string[]; -}; - -type SavePayload = Record; +type SavePayload = Record>; type Props = { - groupId: string; + // Built-in notifications group (renders /notifications/schema?group=…). + groupId?: string; + // Or point at any endpoint returning { schema, values } (e.g. Texty Pro's + schemaPath?: string; + savePath?: string; }; -// The full plugin-ui Settings schema is built on the server -// (`GET /texty/v1/notifications/schema?group=…`). This component only fetches -// it, renders it, and maps edits back into the save payload. -const NotificationGroupSettings = ({ groupId }: Props) => { +// The full plugin-ui Settings schema is built on the server. This component only +// fetches it, renders it, and folds each collapsible card's children back into +// the per-id save payload — generic over any group/feature that follows the +// `` + `_` / `::` field convention. +const NotificationGroupSettings = ({ + groupId, + schemaPath, + savePath, +}: Props) => { + const fetchPath = + schemaPath ?? + addQueryArgs('/texty/v1/notifications/schema', { group: groupId ?? '' }); + const postPath = savePath ?? '/texty/v1/notifications'; const [schema, setSchema] = useState(null); // plugin-ui is controlled for `values` (external values take // precedence over its internal edits), so edits must be lifted into state @@ -68,11 +74,7 @@ const NotificationGroupSettings = ({ groupId }: Props) => { const load = async (): Promise => { setLoading(true); try { - const resp = await apiFetch({ - path: `/texty/v1/notifications/schema?group=${encodeURIComponent( - groupId - )}`, - }); + const resp = await apiFetch({ path: fetchPath }); if (cancelled) { return; } @@ -96,7 +98,7 @@ const NotificationGroupSettings = ({ groupId }: Props) => { return () => { cancelled = true; }; - }, [groupId]); + }, [fetchPath]); const handleChange = ( _scopeId: string, @@ -106,39 +108,44 @@ const NotificationGroupSettings = ({ groupId }: Props) => { setValues((prev: Record) => ({ ...prev, [key]: value })); }; - // Each `collapsible_switch` field id is a notification id; only role-type - // notifications carry a recipients child. Posts only this group's ids — the - // backend merges them over the stored option, preserving the other groups. + // Each `collapsible_switch` field id is a record id; its children + // (`_message`, `_recipients`, or `::`) are folded back into a + // nested entry keyed by the part after the id + separator. Posts only this + // group's ids — the backend merges them over the stored option. const handleSave = async (): Promise => { if (!schema) { return; } const cur = values; - const roleIds = new Set( - schema - .filter((el: SettingsElement) => el.variant === 'multicheck') - .map((el: SettingsElement) => String(el.field_group_id)) - ); - const payload: SavePayload = {}; + for (const el of schema) { if (el.variant !== 'collapsible_switch') { continue; } const id = el.id; - const message = cur[`${id}${SEP}message`]; - const entry: SavePayloadEntry = { - enabled: Boolean(cur[id]), - message: typeof message === 'string' ? message : '', - }; - - if (roleIds.has(id)) { - const recipients = cur[`${id}${SEP}recipients`]; - entry.recipients = Array.isArray(recipients) - ? (recipients as string[]) - : []; + const entry: Record = { enabled: Boolean(cur[id]) }; + + for (const child of schema) { + if (child.field_group_id !== id || child.variant === 'info') { + continue; + } + + const childId = String(child.id); + const key = ( + childId.startsWith(id) ? childId.slice(id.length) : childId + ).replace(/^(::|_)/, ''); + + let value = cur[childId]; + if (child.variant === 'multicheck') { + value = Array.isArray(value) ? value : []; + } else if (key === 'message') { + value = typeof value === 'string' ? value : ''; + } + + entry[key] = value; } payload[id] = entry; @@ -146,11 +153,7 @@ const NotificationGroupSettings = ({ groupId }: Props) => { setSaving(true); try { - await apiFetch({ - path: '/texty/v1/notifications', - method: 'POST', - data: payload, - }); + await apiFetch({ path: postPath, method: 'POST', data: payload }); toast.success(__('Changes saved.', 'texty')); } catch (err) { console.error('Failed to save notifications', err); @@ -169,15 +172,24 @@ const NotificationGroupSettings = ({ groupId }: Props) => { } // Heading is rendered here (plugin-ui's built-in heading has no icon slot). + // Add-ons can register icons/logos for their own groups via these filters. const page = schema.find((el: SettingsElement) => el.type === 'page'); - const HeaderIcon = ICON_MAP[String(page?.icon ?? '')] ?? Bell; - const logoFile = LOGO_MAP[String(page?.id ?? '')]; + const iconMap = applyFilters( + 'texty_notification_icon_map', + ICON_MAP + ) as Record>; + const logoMap = applyFilters( + 'texty_notification_logo_map', + LOGO_MAP + ) as Record; + const HeaderIcon = iconMap[String(page?.icon ?? '')] ?? Bell; + const logoFile = logoMap[String(page?.id ?? '')]; const title = page?.label ?? ''; const description = page?.description ?? ''; return (
-
+
{logoFile ? ( { const load = async (): Promise => { try { const response = await apiFetch({ - path: '/texty/v1/notifications?context=edit', + path: addQueryArgs('/texty/v1/notifications', { context: 'edit' }), method: 'GET', }); if (cancelled) { diff --git a/src/pages/notifications/index.tsx b/src/pages/notifications/index.tsx index a87bc0d..dc7633c 100644 --- a/src/pages/notifications/index.tsx +++ b/src/pages/notifications/index.tsx @@ -1,35 +1,79 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from '@wedevs/plugin-ui'; +import { useMemo } from '@wordpress/element'; +import { applyFilters } from '@wordpress/hooks'; import { __ } from '@wordpress/i18n'; +import { type ReactNode } from 'react'; +import { useSearchParams } from 'react-router-dom'; import Integrations from './Integrations'; import Settings from './Settings'; import UserEvents from './UserEvents'; +type NotificationTab = { + value: string; + label: string; + content: ReactNode; +}; + +const TAB_PARAM = 'tab'; + const Notifications = () => { + const [searchParams, setSearchParams] = useSearchParams(); + + const tabs = useMemo( + () => [ + { + value: 'user-events', + label: __('User Events', 'texty'), + content: , + }, + { + value: 'integrations', + label: __('Integrations', 'texty'), + content: , + }, + ...(applyFilters('texty_notifications_tabs', []) as NotificationTab[]), + { + value: 'settings', + label: __('Settings', 'texty'), + content: , + }, + ], + [], + ); + + const requested = searchParams.get(TAB_PARAM); + const activeTab = tabs.some((tab) => tab.value === requested) + ? (requested as string) + : (tabs[0]?.value ?? ''); + + const handleTabChange = (value: string): void => { + setSearchParams( + (prev) => { + const next = new URLSearchParams(prev); + next.set(TAB_PARAM, value); + return next; + }, + { replace: true }, + ); + }; + return (
- + - - {__('User Events', 'texty')} - - - {__('Integrations', 'texty')} - - {__('Settings', 'texty')} + {tabs.map((tab) => ( + + {tab.label} + + ))} - - - - - - - - - - - + {tabs.map((tab) => ( + + {tab.content} + + ))}
); diff --git a/texty.php b/texty.php index e3addab..1c5bb58 100644 --- a/texty.php +++ b/texty.php @@ -86,6 +86,7 @@ public function init_plugin() { new Texty\Admin(); } + new Texty\Assets(); new Texty\Api(); new Texty\Dispatcher(); new Texty\Compliance(); @@ -114,6 +115,16 @@ private function init_datalayer() { Texty\Models\SmsStat::class, Texty\Models\SmsStatStore::class ); + + /** + * Fires after the DataLayerFactory is initialized and Texty's own + * stores are registered. Add-ons should register their DataLayer + * models/stores here (via DataLayerFactory::register_store) so the + * factory is guaranteed ready and prefixed. Runs before texty_loaded. + * + * @param Texty $texty The main plugin instance. + */ + do_action( 'texty_datalayer_init', $this ); } catch ( \Exception $e ) { error_log( 'Texty DataLayer Init Error: ' . $e->getMessage() ); } diff --git a/webpack-dependency-mapping.js b/webpack-dependency-mapping.js new file mode 100644 index 0000000..aeb2450 --- /dev/null +++ b/webpack-dependency-mapping.js @@ -0,0 +1,33 @@ +/** + * Maps Texty's reusable packages to the globals/handles they are exposed under, + * so add-ons (e.g. Texty Pro) can `import` them and have webpack externalize the + * import to the shared runtime (dependency extraction) instead of bundling a + * second copy. + * + * Mirrors the Dokan lite → pro pattern. + */ + +/** + * @param {string} request Import request. + * @return {string|undefined} Global variable the request resolves to. + */ +const requestToExternal = ( request ) => { + if ( request === '@texty/components' ) { + return [ 'texty', 'components' ]; + } +}; + +/** + * @param {string} request Import request. + * @return {string|undefined} Script handle the request depends on. + */ +const requestToHandle = ( request ) => { + if ( request === '@texty/components' ) { + return 'texty-components'; + } +}; + +module.exports = { + requestToExternal, + requestToHandle, +}; diff --git a/webpack.config.js b/webpack.config.js index a39d620..cf83c64 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -1,8 +1,21 @@ const path = require('path'); const defaultConfig = require('@wordpress/scripts/config/webpack.config'); +const DependencyExtractionWebpackPlugin = require('@wordpress/dependency-extraction-webpack-plugin'); +const { requestToExternal, requestToHandle } = require('./webpack-dependency-mapping'); module.exports = { ...defaultConfig, + entry: { + index: './src/index.tsx', + // Reusable components exposed for add-ons (window.texty.components). + components: { + import: './src/components/index.tsx', + library: { + name: ['texty', 'components'], + type: 'window', + }, + }, + }, output: { ...defaultConfig.output, path: path.resolve(__dirname, 'dist'), @@ -24,4 +37,15 @@ module.exports = { watchOptions: { ignored: ["**/dist/**"], // Ignore the generated build files to avoid unnecessary rebuilds }, + plugins: [ + // Replace the default dependency-extraction plugin with one that also knows + // how to externalize Texty's own packages (@texty/*) for add-ons. + ...defaultConfig.plugins.filter( + (plugin) => plugin.constructor.name !== 'DependencyExtractionWebpackPlugin' + ), + new DependencyExtractionWebpackPlugin({ + requestToExternal, + requestToHandle, + }), + ], };