Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
# Changelog

## [0.4.0] - 2026-08-07
### Added
- `entityResolve` callback option on `ConfOptions` — resolves business-entity detail for the message editor's chip hover-card
- Optional `$language` parameter on `SnippetClient::snippet()` to force the widget UI language (`data-lang`)

## [0.3.4] - 2026-08-07
### Added
- `CspPolicy` — builds the Content-Security-Policy directives the host page needs (`script-src`, `connect-src`, `style-src`, `img-src`, `frame-src`); exposes `getDirectives()`, `getHeaderName()`, `getHeaderValue()` and `getMetaTag()`. Only the CDN origin comes from the loader URL; the API and application origins are taken from the environment or from the `apiUrl` / `applicationUrl` arguments, and an unknown origin throws a `CspException` instead of producing a guessed policy that would silently block the widget
Expand All @@ -9,6 +14,14 @@
- `CspException` for an invalid nonce or an origin that is neither known nor given
- CSP section in the README and a runnable `examples/csp.php`

## [0.3.3] - 2026-06-09
### Fixed
- Default value handling in `SnippetOptions` for properties without a declared default (follow-up to 0.3.2 — the previous fix still relied on `null` in one more spot)

## [0.3.2] - 2026-06-08
### Fixed
- Default value resolution in `SnippetOptions` for properties without a default — `getDefaultValue()` was called unguarded, relying on `null` for properties that have no declared default instead of checking `hasDefaultValue()` first

## [0.3.1] - 2026-04-17
### Changed
- Add theme support to ConfOptions
Expand Down
9 changes: 8 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -251,7 +251,7 @@ $client = new SnippetClient('key', 'secret', codeHasher: new MyCustomHasher());
## Methods
| Method | Description |
|---|---|
| `snippet()` | Async loader script. Place once per page. |
| `snippet(?string $language = null)` | Async loader script. Place once per page. Optional UI language (`cs`, `en`, `sk`…), otherwise auto-detected from the browser. |
| `conf(ConfOptions)` | SDK configuration (notification renderer, CSS, callbacks…). |
| `user(UserOptions)` | Identifies the current user. |
| `thread(string $selector, ThreadOptions)` | Embeds a thread into a DOM element. |
Expand Down Expand Up @@ -285,6 +285,13 @@ See the full list of parameters in [`src/Options/ConfOptions.php`](src/Options/C
| `notificationElementTargetElement` | `?string` | JS expression returning the target DOM element. |
| `notificationElementPosition` | `?int` | Icon position: 1=top-left, 2=top-right, 3=bottom-right, 4=bottom-left. |
| `theme` | `?string` | Light/dark mode for the app. Values: `null` (follows browser preference), `stromcom-light`, `stromcom-dark`. |
| `entityResolve` | `?string` | JS callback resolving business-entity detail (order, ticket…) for the message editor's chip hover-card. Receives `{type, id}`, returns (or resolves to) `{title, url?, fields: [{label, value}]}`. |

### UI language
Pass a language code to `snippet()` to force the widget UI language (`cs`, `en`, `sk`…). When omitted, the widget detects it from the browser, falling back to English.
```php
echo $client->snippet('cs')->getHTML();
```
## Avatar helper
`AvatarStyle` generates Gravatar URLs without any external dependency:
```php
Expand Down
7 changes: 5 additions & 2 deletions src/Internal/Generator.php
Original file line number Diff line number Diff line change
Expand Up @@ -31,18 +31,21 @@ public function __construct(?string $dataLayer = null, bool $withDocs = false, ?
$this->nonce = $nonce;
}

public function generateSnippet(string $loaderUrl, string $clientKey, string $clientSecret): SnippetCode {
public function generateSnippet(string $loaderUrl, string $clientKey, string $clientSecret, ?string $language = null): SnippetCode {
try {
$layer = $this->jsonEncode($this->dataLayer);
$url = $this->jsonEncode($loaderUrl . '?');
$key = $this->jsonEncode($clientKey);
$secret = $this->jsonEncode($clientSecret);
$lang = $language !== null ? $this->jsonEncode($language) : null;

$langAssign = $lang !== null ? "j.dataset.lang={$lang};" : '';

return new SnippetCode(<<<JS
(function(win, d, e, l, k, s) {
var dl=l+'DL',c=function(s,n,a){n=n||s;(a?win[dl][n]=[]:null);win[l][s]=function(...p){a?win[dl][n].push(p):win[dl][n]=p;}},f=d.getElementsByTagName(e)[0],j=d.createElement(e);
win[dl]=win[dl]||{};win[l]={};c('initUser','user');c('thread','threads',!0);c('conf');c('home',0,!0);
;j.async=true;j.dataset.type='stromcom';j.dataset.l=l;j.dataset.dl=dl;j.dataset.ck=k;j.dataset.cs=s;
;j.async=true;j.dataset.type='stromcom';j.dataset.l=l;j.dataset.dl=dl;j.dataset.ck=k;j.dataset.cs=s;{$langAssign}
j.src = {$url}+k;f.parentNode.insertBefore(j,f);
})(window, document, 'script', {$layer}, {$key}, {$secret});
JS, $this->nonce);
Expand Down
13 changes: 13 additions & 0 deletions src/Options/ConfOptions.php
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,9 @@ class ConfOptions extends SnippetOptions {
#[Docs('Theme name applied to the host page via data-theme on <html>. Available: stromcom-default, stromcom-dark', 'stromcom-dark', true)]
private ?string $theme = null;

#[Docs('Callback resolving business-entity detail for the message editor chip hover-card. Receives {type, id}, returns an object (or Promise) with {title, url?, fields: [{label, value}]}', null, true, 'Function|null')]
private ?string $entityResolve = null;

/** @param array<string, string|int|float|null>|null $notificationElementStyles */
public function __construct(
?string $notificationRenderer = null,
Expand All @@ -63,6 +66,7 @@ public function __construct(
?string $notificationElementAfterRender = null,
?string $homeBeforeRender = null,
?string $theme = null,
?string $entityResolve = null,
) {
$this->notificationRenderer = $notificationRenderer;
$this->onNotification = $onNotification;
Expand All @@ -77,6 +81,7 @@ public function __construct(
$this->notificationElementAfterRender = $notificationElementAfterRender;
$this->homeBeforeRender = $homeBeforeRender;
$this->theme = $theme;
$this->entityResolve = $entityResolve;
}

public function getNotificationRenderer(): ?string {
Expand Down Expand Up @@ -156,6 +161,14 @@ public function getTheme(): ?string {
return $this->theme;
}

public function getEntityResolve(): ?string {
return $this->entityResolve;
}

public function renderEntityResolve(): ?JsValue {
return $this->wrapJsValue($this->entityResolve);
}

private function wrapJsValue(?string $value): ?JsValue {
return $value !== null ? JsValue::createDOMContentLoaded($value) : null;
}
Expand Down
6 changes: 5 additions & 1 deletion src/SnippetClient.php
Original file line number Diff line number Diff line change
Expand Up @@ -59,13 +59,17 @@ public function csp(): CspPolicy {
* Generates the async loader snippet that bootstraps the SDK.
* Place this once on every page where you want the widget to appear.
*
* @param string|null $language UI language (e.g. "cs", "en", "sk"). Null (default) lets the
* widget detect it from the browser, falling back to English.
*
* @throws SnippetGenerationException
*/
public function snippet(): SnippetCode {
public function snippet(?string $language = null): SnippetCode {
return $this->generator->generateSnippet(
$this->environment->getLoaderUrl(),
$this->clientKey,
$this->clientSecret,
$language,
);
}

Expand Down
15 changes: 15 additions & 0 deletions tests/Options/ConfOptionsTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,21 @@ public function with_docs_excludes_fields_with_show_null_in_docs_false(): void {
$this->assertArrayNotHasKey('notificationElementShowAlways', $result);
}

#[Test]
public function entity_resolve_string_is_wrapped_in_js_value(): void {
$options = new ConfOptions(entityResolve: 'async ({type, id}) => ({title: `${type} #${id}`})');
$result = $options->getOptions();

$this->assertArrayHasKey('entityResolve', $result);
$this->assertInstanceOf(JsValue::class, $result['entityResolve']);
}

#[Test]
public function entity_resolve_null_renders_as_null(): void {
$options = new ConfOptions();
$this->assertNull($options->renderEntityResolve());
}

#[Test]
public function get_options_with_docs_returns_all_property_schemas(): void {
$schema = ConfOptions::getOptionsWithDocs();
Expand Down
12 changes: 12 additions & 0 deletions tests/SnippetClientTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,18 @@ public function snippet_uses_custom_environment_url(): void {
$this->assertStringContainsString('localhost:9000', $client->snippet()->getCode());
}

#[Test]
public function snippet_without_language_omits_lang_dataset(): void {
$code = $this->client->snippet()->getCode();
$this->assertStringNotContainsString('dataset.lang', $code);
}

#[Test]
public function snippet_with_language_sets_lang_dataset(): void {
$code = $this->client->snippet('cs')->getCode();
$this->assertStringContainsString('j.dataset.lang="cs"', $code);
}

#[Test]
public function snippet_html_is_wrapped_in_script_tags(): void {
$html = $this->client->snippet()->getHTML();
Expand Down