From af61b4a0aeac9a616b206c70fa113b1b066949d2 Mon Sep 17 00:00:00 2001 From: Davis Shaver Date: Fri, 13 Dec 2024 11:27:38 -0500 Subject: [PATCH] feat: add notifications feature --- farcaster-wp.php | 4 +- includes/api/class-manifest-controller.php | 15 +- includes/api/class-webhook-controller.php | 133 ++++++++ includes/class-admin.php | 4 + includes/class-api.php | 7 + includes/class-frames.php | 12 +- includes/class-initializer.php | 1 + includes/class-notifications.php | 378 +++++++++++++++++++++ package.json | 4 +- readme.txt | 4 +- src/components/Controls.tsx | 12 + src/components/ManifestViewer.tsx | 25 +- src/components/SettingsPage.tsx | 13 + src/hooks/use-settings.ts | 9 + src/sdk.ts | 95 +++++- src/utils/toast.ts | 69 ++++ 16 files changed, 767 insertions(+), 18 deletions(-) create mode 100644 includes/api/class-webhook-controller.php create mode 100644 includes/class-notifications.php create mode 100644 src/utils/toast.ts diff --git a/farcaster-wp.php b/farcaster-wp.php index 66b257e..8d108b8 100644 --- a/farcaster-wp.php +++ b/farcaster-wp.php @@ -10,7 +10,7 @@ * Plugin Name: Farcaster WP * Plugin URI: https://farcaster-wp.davisshaver.com/ * Description: Farcaster WP connects your WordPress site to Farcaster. - * Version: 0.0.16 + * Version: 0.0.17 * Author: Davis Shaver * Author URI: https://davisshaver.com/ * License: GPL v2 or later @@ -22,7 +22,7 @@ defined( 'ABSPATH' ) || exit; -define( 'FARCASTER_WP_VERSION', '0.0.16' ); +define( 'FARCASTER_WP_VERSION', '0.0.17' ); define( 'FARCASTER_WP_API_NAMESPACE', 'farcaster-wp/v1' ); define( 'FARCASTER_WP_API_URL', get_site_url() . '/wp-json/' . FARCASTER_WP_API_NAMESPACE ); diff --git a/includes/api/class-manifest-controller.php b/includes/api/class-manifest-controller.php index 6770b49..f83a9a3 100644 --- a/includes/api/class-manifest-controller.php +++ b/includes/api/class-manifest-controller.php @@ -1,6 +1,6 @@ [ 'header' => $header, @@ -99,11 +101,14 @@ public function get_manifest() { 'iconUrl' => get_site_icon_url(), 'splashImageUrl' => $splash_image_url, 'splashBackgroundColor' => $splash_background_color, - // @TODO: Add API endpoint for webhook to do... something? - // 'webhookUrl' => '', + ], ]; + if ( Notifications::are_enabled() ) { + $manifest['frame']['webhookUrl'] = get_rest_url( null, FARCASTER_WP_API_NAMESPACE . '/webhook' ); + } + return new WP_REST_Response( $manifest ); } @@ -112,7 +117,7 @@ public function get_manifest() { * * @return array */ - public function get_app_config_schema() { + public function get_manifest_schema() { return [ '$schema' => 'http://json-schema.org/draft-04/schema#', 'title' => $this->resource_name, diff --git a/includes/api/class-webhook-controller.php b/includes/api/class-webhook-controller.php new file mode 100644 index 0000000..628676d --- /dev/null +++ b/includes/api/class-webhook-controller.php @@ -0,0 +1,133 @@ +namespace, + '/' . $this->resource_name, + [ + [ + 'methods' => 'POST', + 'callback' => [ $this, 'process_webhook' ], + 'validate_callback' => [ $this, 'validate_webhook' ], + ], + 'schema' => [ $this, 'get_webhook_schema' ], + ] + ); + } + + /** + * Process the webhook. + * + * @param WP_REST_Request $request The request object. + * @return WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure. + */ + public function process_webhook( $request ) { + $body = $request->get_body(); + $data = json_decode( $body, true ); + $header = json_decode( base64_decode( $data['header'] ), true ); + $payload = json_decode( base64_decode( $data['payload'] ), true ); + $response = Notifications::process_webhook( $header, $payload ); + return new WP_REST_Response( $response ); + } + + /** + * Validate the webhook. + * + * @param WP_REST_Request $request The request object. + * @return bool True if the webhook is valid, false otherwise. + */ + public function validate_webhook( $request ) { + $body = $request->get_body(); + $data = json_decode( $body, true ); + + if ( empty( $data['header'] ) || empty( $data['payload'] ) || empty( $data['signature'] ) ) { + return new WP_Error( 'invalid_webhook_parameters', 'Invalid webhook parameters', [ 'status' => 400 ] ); + } + + $header = json_decode( base64_decode( $data['header'] ), true ); + $payload = json_decode( base64_decode( $data['payload'] ), true ); + + if ( empty( $header['fid'] ) || empty( $header['type'] ) || empty( $header['key'] ) ) { + return new WP_Error( 'invalid_webhook_header', 'Invalid webhook header', [ 'status' => 400 ] ); + } + + if ( empty( $payload['event'] ) || ! in_array( $payload['event'], [ 'frame_added', 'frame_removed', 'notifications_disabled', 'notifications_enabled' ] ) ) { + return new WP_Error( 'invalid_webhook_payload', 'Invalid webhook payload', [ 'status' => 400 ] ); + } + + // We are only handling frame_added and notifications_enabled events if they have notificationDetails. + if ( + in_array( $payload['event'], [ 'frame_added', 'notifications_enabled' ] ) && + ( empty( $payload['notificationDetails'] ) || empty( $payload['notificationDetails']['url'] ) || empty( $payload['notificationDetails']['token'] ) ) + ) { + return new WP_Error( 'invalid_notification_details', 'Invalid notification details', [ 'status' => 400 ] ); + } + + return true; + } + + /** + * Get the REST schema for the endpoints. + * + * @return array + */ + public function get_webhook_schema() { + return [ + '$schema' => 'http://json-schema.org/draft-04/schema#', + 'title' => $this->resource_name, + 'type' => 'object', + 'properties' => [ + 'header' => [ + 'required' => true, + 'type' => 'string', + ], + 'payload' => [ + 'required' => true, + 'type' => 'string', + ], + 'signature' => [ + 'required' => true, + 'type' => 'string', + ], + ], + ]; + } +} diff --git a/includes/class-admin.php b/includes/class-admin.php index 3fbec09..b47c479 100644 --- a/includes/class-admin.php +++ b/includes/class-admin.php @@ -114,6 +114,7 @@ public static function action_init() { 'url' => '', ), 'domain_manifest' => '', + 'notifications_enabled' => false, ); $schema = array( 'type' => 'object', @@ -121,6 +122,9 @@ public static function action_init() { 'frames_enabled' => array( 'type' => 'boolean', ), + 'notifications_enabled' => array( + 'type' => 'boolean', + ), 'splash_background_color' => array( 'type' => 'string', ), diff --git a/includes/class-api.php b/includes/class-api.php index 5c7da50..1d5761d 100644 --- a/includes/class-api.php +++ b/includes/class-api.php @@ -8,6 +8,7 @@ namespace Farcaster_WP; use Farcaster_WP\API\Manifest_Controller; +use Farcaster_WP\API\Webhook_Controller; defined( 'ABSPATH' ) || exit; @@ -23,5 +24,11 @@ public static function init() { include_once 'api/class-manifest-controller.php'; $manifest_api = new Manifest_Controller(); add_action( 'rest_api_init', [ $manifest_api, 'register_routes' ] ); + + if ( Notifications::are_enabled() ) { + include_once 'api/class-webhook-controller.php'; + $webhook_api = new Webhook_Controller(); + add_action( 'rest_api_init', [ $webhook_api, 'register_routes' ] ); + } } } diff --git a/includes/class-frames.php b/includes/class-frames.php index e7c08e9..2c58a1e 100644 --- a/includes/class-frames.php +++ b/includes/class-frames.php @@ -130,8 +130,9 @@ public static function action_wp_head() { * Enqueue scripts. */ public static function action_enqueue_scripts() { - $options = get_option( 'farcaster_wp', array() ); - + $options = get_option( 'farcaster_wp', array() ); + $notifications_enabled = $options['notifications_enabled'] ?? false; + // Only enqueue if frames are enabled in settings. if ( ! empty( $options['frames_enabled'] ) ) { wp_enqueue_script( @@ -144,6 +145,13 @@ public static function action_enqueue_scripts() { 'strategy' => 'defer', ) ); + wp_localize_script( + 'farcaster-frame-sdk', + 'farcasterWP', + array( + 'notificationsEnabled' => $notifications_enabled, + ) + ); } } } diff --git a/includes/class-initializer.php b/includes/class-initializer.php index 514ad9e..8a5cd93 100644 --- a/includes/class-initializer.php +++ b/includes/class-initializer.php @@ -19,5 +19,6 @@ public static function init() { Admin::init(); API::init(); Frames::init(); + Notifications::init(); } } diff --git a/includes/class-notifications.php b/includes/class-notifications.php new file mode 100644 index 0000000..0587e34 --- /dev/null +++ b/includes/class-notifications.php @@ -0,0 +1,378 @@ +post_type ) { + self::schedule_publish_post_notifications( $post->ID ); + } + } + + /** + * Schedule the notifications. + * + * @param int $post_id The post ID. + */ + public static function schedule_publish_post_notifications( $post_id ) { + wp_schedule_single_event( time(), 'farcaster_wp_send_publish_post_notifications', array( $post_id ) ); + } + + /** + * Send the notifications. + * + * @param int $post_id The post ID. + */ + public static function send_publish_post_notifications( $post_id ) { + $post = get_post( $post_id ); + if ( ! empty( $post ) ) { + self::initiate_notifications( $post ); + } + } + + /** + * Get the notification ID. + * + * @param int $post_id The post ID. + * @return string The notification ID. + */ + public static function get_notification_id( $post_id ) { + $blog_name = str_replace( '-', '_', sanitize_title_with_dashes( get_bloginfo( 'name' ) ) ); + return 'farcaster_wp_notification_' . $post_id . '_' . $blog_name; + } + + /** + * Get the tokens by url. + * + * @return array The tokens by url. + */ + public static function get_tokens_by_url() { + $subscriptions = get_option( self::$notifications_option_name, array() ); + $tokens_by_url = array(); + foreach ( $subscriptions as $fid => $apps ) { + foreach ( $apps as $app_key => $app ) { + $tokens_by_url[ $app['url'] ][] = $app['token']; + } + } + return $tokens_by_url; + } + + /** + * Retry notifications. + * + * @param string $url The URL. + * @param array $tokens The tokens. + * @param int $post_id The post ID. + */ + public static function retry_notifications( $url, $tokens, $post_id ) { + $notification_body = self::get_notification_body( $post_id ); + self::chunk_and_send_notifications( $tokens, $notification_body, $url, $post_id ); + } + + /** + * Send the notifications. + * + * @param object $post The post object. + */ + public static function initiate_notifications( $post ) { + $tokens_by_app = self::get_tokens_by_url(); + self::send_notifications_by_app( $tokens_by_app, $post->ID ); + } + + /** + * Get the notification body. + * + * @param int $post_id The post ID. + * @return array The notification body. + */ + public static function get_notification_body( $post_id ) { + return array( + 'notificationId' => self::get_notification_id( $post_id ), + 'title' => substr( wp_strip_all_tags( get_the_title( $post_id ) ), 0, 32 ), + 'body' => substr( wp_strip_all_tags( get_the_excerpt( $post_id ) ), 0, 128 ), + 'targetUrl' => get_permalink( $post_id ), + ); + } + + /** + * Send the notifications by app. + * + * @param array $tokens_by_app The tokens by app. + * @param int $post_id The post ID. + */ + public static function send_notifications_by_app( $tokens_by_app, $post_id ) { + $notification_body = self::get_notification_body( $post_id ); + foreach ( $tokens_by_app as $url => $tokens ) { + self::chunk_and_send_notifications( $tokens, $notification_body, $url, $post_id ); + } + } + + /** + * Send the notification. + * + * @param string $url The URL. + * @param array $notification_body The notification body. + * @param int $post_id The post ID. + * @return array The response. + */ + public static function send_notification( $url, $notification_body, $post_id ) { + $response = wp_safe_remote_post( $url, array( 'body' => $notification_body ) ); + if ( is_wp_error( $response ) ) { + $admin_email = get_option( 'admin_email' ); + $error_message = $response->get_error_message(); + + // phpcs:ignore WordPressVIPMinimum.Functions.RestrictedFunctions.wp_mail_wp_mail + wp_mail( + $admin_email, + 'Farcaster Notification Error', + sprintf( + 'There was an error sending notifications: %s. A retry has been scheduled.', + $error_message + ) + ); + + $unsent_tokens = $notification_body['tokens']; + wp_schedule_single_event( + time() + 300, + 'farcaster_wp_retry_notifications', + array( + $url, + $unsent_tokens, + $post_id, + ) + ); + return array(); // Return empty array since request failed. + } + return json_decode( wp_remote_retrieve_body( $response ), true ); + } + + /** + * Chunk and send the notifications. + * + * @param array $tokens The tokens. + * @param array $notification_body The notification body. + * @param string $url The URL. + * @param int $post_id The post ID. + */ + public static function chunk_and_send_notifications( $tokens, $notification_body, $url, $post_id ) { + $chunks = array_chunk( $tokens, 100 ); + foreach ( $chunks as $chunk ) { + $notification_body['tokens'] = $chunk; + $webhook_response = self::send_notification( $url, $notification_body, $post_id ); + + if ( ! empty( $webhook_response['result'] ) ) { + // Add succesful tokens to post meta. + if ( ! empty( $webhook_response['result']['successfulTokens'] ) ) { + $current_tokens = get_post_meta( $post_id, 'farcaster_wp_tokens', true ); + $current_tokens = is_array( $current_tokens ) ? $current_tokens : array(); + $current_tokens = array_merge( $current_tokens, $webhook_response['result']['successfulTokens'] ); + update_post_meta( $post_id, 'farcaster_wp_tokens', $current_tokens ); + } + + // Process invalid tokens and remove them from the subscription. + if ( ! empty( $webhook_response['result']['invalidTokens'] ) ) { + foreach ( $webhook_response['result']['invalidTokens'] as $invalid_token ) { + self::remove_subscription_by_token( $invalid_token, $url ); + } + } + + // Schedule a retry for the rate limited tokens. + if ( ! empty( $webhook_response['result']['rateLimitedTokens'] ) ) { + $rate_limited_tokens = $webhook_response['result']['rateLimitedTokens']; + wp_schedule_single_event( + time() + 300, + 'farcaster_wp_retry_notifications', + array( + $url, + $rate_limited_tokens, + $post_id, + ) + ); + } + } + } + } + + /** + * Process the webhook. + * + * @param array $header The webhook header. + * @param array $payload The webhook payload. + * @return array The response. + * @throws \Exception If the event is invalid. + */ + public static function process_webhook( $header, $payload ) { + $event = $payload['event']; + switch ( $event ) { + case 'frame_added': + return self::process_frame_added( $header, $payload ); + case 'frame_removed': + return self::process_frame_removed( $header ); + case 'notifications_disabled': + return self::process_notifications_disabled( $header ); + case 'notifications_enabled': + return self::process_notifications_enabled( $header, $payload ); + } + throw new \Exception( 'Invalid event: ' . esc_html( $event ) ); + } + + /** + * Add a subscription. + * + * Note: This data structure means that each FID + app key combo can only have one subscription. + * + * @param int $fid The Farcaster ID. + * @param string $key The app key (onchainer signer) public key. + * @param string $url The URL for notifications. + * @param string $token The token for notifications. + * @return array The response. + */ + public static function add_subscription( $fid, $key, $url, $token ) { + $current_subscriptions = get_option( self::$notifications_option_name, array() ); + $current_subscriptions[ $fid ][ $key ] = [ + 'url' => $url, + 'token' => $token, + ]; + update_option( self::$notifications_option_name, $current_subscriptions ); + return [ 'success' => true ]; + } + + /** + * Remove a subscription by token. + * + * @param string $token The token. + * @param string $url The URL. + */ + public static function remove_subscription_by_token( $token, $url ) { + $current_subscriptions = get_option( self::$notifications_option_name, array() ); + foreach ( $current_subscriptions as $fid => $apps ) { + foreach ( $apps as $app_key => $app ) { + if ( $app['url'] === $url && $app['token'] === $token ) { + self::remove_subscription( $fid, $app_key ); + } + } + } + } + + /** + * Remove a subscription. + * + * @param int $fid The Farcaster ID. + * @param string $key The app key (onchainer signer) public key. + * @return array The response. + */ + private static function remove_subscription( $fid, $key ) { + $current_subscriptions = get_option( self::$notifications_option_name, array() ); + if ( ! empty( $current_subscriptions[ $fid ] ) && ! empty( $current_subscriptions[ $fid ][ $key ] ) ) { + unset( $current_subscriptions[ $fid ][ $key ] ); + update_option( self::$notifications_option_name, $current_subscriptions ); + } + return [ 'success' => true ]; + } + + /** + * Process the frame added event. + * + * @param array $header The webhook header. + * @param array $payload The webhook payload. + * @return array The response. + */ + public static function process_frame_added( $header, $payload ) { + $fid = $header['fid']; + $key = $header['key']; + $url = $payload['notificationDetails']['url']; + $token = $payload['notificationDetails']['token']; + return self::add_subscription( $fid, $key, $url, $token ); + } + + /** + * Process the frame removed event. + * + * @param array $header The webhook header. + * @return array The response. + */ + public static function process_frame_removed( $header ) { + $fid = $header['fid']; + $key = $header['key']; + return self::remove_subscription( $fid, $key ); + } + + /** + * Process the notifications disabled event. + * + * @param array $header The webhook header. + * @return array The response. + */ + public static function process_notifications_disabled( $header ) { + $fid = $header['fid']; + $key = $header['key']; + $current_subscriptions = get_option( self::$notifications_option_name, array() ); + if ( ! empty( $current_subscriptions[ $fid ] ) && ! empty( $current_subscriptions[ $fid ][ $key ] ) ) { + unset( $current_subscriptions[ $fid ][ $key ] ); + update_option( self::$notifications_option_name, $current_subscriptions ); + } + return [ 'success' => true ]; + } + + /** + * Process the notifications enabled event. + * + * @param array $header The webhook header. + * @param array $payload The webhook payload. + * @return array The response. + */ + public static function process_notifications_enabled( $header, $payload ) { + $fid = $header['fid']; + $key = $header['key']; + $url = $payload['notificationDetails']['url']; + $token = $payload['notificationDetails']['token']; + return self::add_subscription( $fid, $key, $url, $token ); + } +} diff --git a/package.json b/package.json index afc7fa7..76d61cd 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "farcaster-wp", - "version": "0.0.16", + "version": "0.0.17", "description": "Farcaster WP connects your WordPress site to Farcaster.", "author": "Davis Shaver", "license": "GPL-2.0-or-later", @@ -49,6 +49,6 @@ "lint:php": "./vendor/bin/phpcs", "lint:ts": "tsc --noEmit", "lint:pkg-json": "wp-scripts lint-pkg-json", - "start": "wp-scripts start" + "start": "wp-scripts start src/index src/sdk" } } diff --git a/readme.txt b/readme.txt index 30af237..9e5c134 100644 --- a/readme.txt +++ b/readme.txt @@ -4,7 +4,7 @@ Tags: WordPress, web3, Farcaster, Ethereum Tested up to: 6.7.1 Requires at least: 6.7.0 Requires PHP: 7.0 -Stable tag: 0.0.16 +Stable tag: 0.0.17 License: GPLv2 or later License URI: https://www.gnu.org/licenses/gpl-2.0.html @@ -26,7 +26,7 @@ Farcaster WP makes it easy to setup [Farcaster frames](https://docs.farcaster.xy == Changelog == -= 0.0.16 = += 0.0.17 = * Initial plugin release to WordPress.org == Screenshots = diff --git a/src/components/Controls.tsx b/src/components/Controls.tsx index 50530f9..e6be504 100644 --- a/src/components/Controls.tsx +++ b/src/components/Controls.tsx @@ -61,6 +61,17 @@ const UseTitleAsButtonTextControl = ( { value, onChange } ) => { ); }; +const NotificationsEnabledControl = ( { value, onChange } ) => { + return ( + + ); +}; + const FramesEnabledControl = ( { value, onChange } ) => { return ( ) } + { mismatches.details.webhookUrl && ( + + { __( + 'The manifest webhook URL does not match the current site webhook URL.', + 'farcaster-wp' + ) } + + ) } { mismatches.details.header && ( { setUseTitleAsButtonText, domainManifest, setDomainManifest, + notificationsEnabled, + setNotificationsEnabled, } = useSettings(); const { manifest, fetchManifest } = useManifest(); @@ -132,6 +135,16 @@ const SettingsPage = () => { + + + + + + + { const [ domainManifest, setDomainManifest ] = useState< string >(); const [ framesEnabled, setFramesEnabled ] = useState< boolean >(); + const [ notificationsEnabled, setNotificationsEnabled ] = + useState< boolean >( false ); const [ splashBackgroundColor, setSplashBackgroundColor ] = useState< string >(); const [ buttonText, setButtonText ] = useState< string >(); @@ -65,6 +68,9 @@ export const useSettings = () => { settings.farcaster_wp.use_title_as_button_text ); setDomainManifest( settings.farcaster_wp.domain_manifest ); + setNotificationsEnabled( + settings.farcaster_wp.notifications_enabled + ); } ); }, [] ); @@ -107,6 +113,7 @@ export const useSettings = () => { fallback_image: fallbackImage, use_title_as_button_text: useTitleAsButtonText, domain_manifest: domainManifest, + notifications_enabled: notificationsEnabled, }, }, } ) @@ -155,5 +162,7 @@ export const useSettings = () => { setUseTitleAsButtonText, domainManifest, setDomainManifest, + notificationsEnabled, + setNotificationsEnabled, }; }; diff --git a/src/sdk.ts b/src/sdk.ts index 033687e..d3116b7 100644 --- a/src/sdk.ts +++ b/src/sdk.ts @@ -1,3 +1,94 @@ -import { sdk } from '@farcaster/frame-sdk'; +import sdk from '@farcaster/frame-sdk'; +import { showToast } from './utils/toast'; -sdk.actions.ready(); +declare global { + interface Window { + farcasterWP: { + notificationsEnabled: boolean; + }; + } +} + +const loadSdk = async () => { + const context = await sdk.context; + sdk.actions.ready(); + + if ( ! window.farcasterWP.notificationsEnabled ) { + return; + } + + if ( ! context ) { + // No context, probably not in frame. + return; + } + + if ( context?.location?.type === 'notification' ) { + showToast( { + type: 'success', + message: 'Thanks for being a subscriber!', + duration: 3000, + } ); + return; + } + + sdk.actions + .addFrame() + .then( ( result ) => { + if ( result?.added ) { + showToast( { + type: 'success', + message: 'You are subscribed to notifications.', + duration: 3000, + } ); + } else { + showToast( { + message: + 'Would you like to get Warpcast notifications about new posts?', + buttonText: 'Yes!', + onButtonClick: () => { + sdk.actions + .addFrame() + .then( ( retryResult ) => { + // @TODO Technically we could subscribe the user here, but + // for now we are going to wait for the webhook event to arrive. + if ( retryResult && retryResult?.added ) { + showToast( { + message: + 'Successfully subscribed to notifications!', + duration: 3000, + } ); + } else { + showToast( { + type: 'error', + message: + 'Error adding frame, addFrame response: ' + + JSON.stringify( retryResult ), + duration: 3000, + } ); + } + } ) + .catch( ( error ) => { + showToast( { + type: 'error', + message: + 'Error adding frame, addFrame error: ' + + JSON.stringify( error ), + duration: 3000, + } ); + } ); + }, + } ); + } + } ) + .catch( ( error ) => { + showToast( { + type: 'error', + message: + 'Error adding frame, addFrame error: ' + + JSON.stringify( error ), + duration: 3000, + } ); + } ); +}; + +loadSdk(); diff --git a/src/utils/toast.ts b/src/utils/toast.ts new file mode 100644 index 0000000..336288b --- /dev/null +++ b/src/utils/toast.ts @@ -0,0 +1,69 @@ +interface ToastOptions { + buttonText?: string; + duration?: number; + message: string; + onButtonClick?: () => void; + type?: 'prompt' | 'success' | 'error'; +} + +export const showToast = ( { + buttonText, + duration = 10000, + message, + onButtonClick, + type = 'prompt', +}: ToastOptions ) => { + const toast = document.createElement( 'div' ); + + const backgroundColorMap = { + error: '#FF453A', + prompt: '#472A91', + success: '#4CAF50', + }; + + toast.style.cssText = ` + align-items: center; + background: ${ backgroundColorMap[ type ] }; + border-radius: 4px; + bottom: 20px; + box-shadow: 0 2px 5px rgba(0,0,0,0.2); + color: white; + display: flex; + gap: 12px; + left: 50%; + max-width: calc(100% - 80px); + padding: 12px 24px; + position: fixed; + transform: translateX(-50%); + width: max-content; + z-index: 10000; + `; + + toast.textContent = message; + + if ( buttonText && onButtonClick ) { + const button = document.createElement( 'button' ); + button.textContent = buttonText; + button.style.cssText = ` + background: #7C65C1; + border-radius: 4px; + border: none; + color: white; + cursor: pointer; + padding: 8px 16px; + `; + button.onclick = () => { + onButtonClick(); + document.body.removeChild( toast ); + }; + toast.appendChild( button ); + } + + document.body.appendChild( toast ); + + setTimeout( () => { + if ( document.body.contains( toast ) ) { + document.body.removeChild( toast ); + } + }, duration ); +};