diff --git a/src/components/index.tsx b/src/components/index.tsx
new file mode 100644
index 0000000..c5c251f
--- /dev/null
+++ b/src/components/index.tsx
@@ -0,0 +1,22 @@
+/**
+ * Public component surface for add-ons.
+ *
+ * Built as the `components` webpack entry (library `textyComponents`, handle
+ * `texty-components`) and consumed by add-ons via `import { … } from
+ * '@texty/components'`, which webpack externalizes to the shared global.
+ *
+ * PhoneField wraps react-phone-input-2; its base stylesheet is imported here
+ * so the components entry emits its own `dist/components.css`. Add-ons that use
+ * PhoneField on storefront surfaces (where the admin SPA sheet never loads)
+ * pull it in by depending on the `texty-components` style handle.
+ *
+ * The `?texty-components` resource query is load-bearing: the admin `index.tsx`
+ * imports the same `react-phone-input-2/lib/style.css`, and without a distinct
+ * request webpack would extract the single shared module into `style-index.css`
+ * (leaving no components sheet). The query makes this a distinct module so the
+ * components entry gets its own extracted stylesheet.
+ */
+import 'react-phone-input-2/lib/style.css?texty-components';
+
+export { default as NotificationGroupSettings } from '../pages/notifications/components/NotificationGroupSettings';
+export { default as PhoneField } from './PhoneField';
diff --git a/src/pages/notifications/components/NotificationGroupSettings.tsx b/src/pages/notifications/components/NotificationGroupSettings.tsx
index 6ed899b..fd132bb 100644
--- a/src/pages/notifications/components/NotificationGroupSettings.tsx
+++ b/src/pages/notifications/components/NotificationGroupSettings.tsx
@@ -8,20 +8,19 @@ import apiFetch from '@wordpress/api-fetch';
import { useEffect, useState } from '@wordpress/element';
import { applyFilters } from '@wordpress/hooks';
import { __ } from '@wordpress/i18n';
-import { Bell, Globe, ShoppingCart, Store } from 'lucide-react';
+import { addQueryArgs } from '@wordpress/url';
+import { Bell, Globe, ShoppingCart, Store, Zap } from 'lucide-react';
import type { ComponentType } from 'react';
import NotificationGroupSkeleton from './NotificationGroupSkeleton';
-// Separator for child field ids — `
_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,36 @@ type SchemaResponse = {
values: Record;
};
-type SavePayloadEntry = {
- enabled: boolean;
- message: string;
- recipients?: string[];
-};
-
-type SavePayload = Record;
+type SavePayload = Record>;
-type Props = {
- groupId: string;
-};
+type Props =
+ | {
+ // Built-in notifications group (renders /notifications/schema?group=…).
+ groupId: string;
+ schemaPath?: never;
+ savePath?: never;
+ }
+ | {
+ // Or point at any endpoint returning { schema, values } (e.g. Texty Pro's
+ // custom notification features). Both paths are required together.
+ groupId?: never;
+ 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 +81,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 +105,7 @@ const NotificationGroupSettings = ({ groupId }: Props) => {
return () => {
cancelled = true;
};
- }, [groupId]);
+ }, [fetchPath]);
const handleChange = (
_scopeId: string,
@@ -106,39 +115,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 +160,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 +179,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..5fc7847 100644
--- a/src/pages/notifications/index.tsx
+++ b/src/pages/notifications/index.tsx
@@ -1,35 +1,75 @@
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@wedevs/plugin-ui';
+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: NotificationTab[] = [
+ {
+ 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,
+ }),
+ ],
};