# Personi

[![Commercial](https://img.shields.io/badge/License-Commercial-red.svg)](LICENSE.md)
[![Cockpit CMS](https://img.shields.io/badge/Cockpit%20CMS-Addon-blue.svg)](https://getcockpit.com)
[![Personalization](https://img.shields.io/badge/Feature-Personalization-green.svg)](#features)

> Audience‑based content and layout personalization for Cockpit CMS

Personi lets you define multiple content/layout variants and automatically resolves the best matching variant at API time based on the requested audience. Editors author variants visually in the admin; frontend clients select the audience via a simple query parameter.

## ✨ Features

- Variants field type for any model
  - Create multiple variants with label, active state, audience tags, optional meta
  - Configure inner fields that make up a variant’s `data`
- Schedule-based activation
  - Activate variants only within specific date/time windows using `meta.schedule`
- Layout Variants component
  - Drop a "Layout Variants" block into Layout pages to personalize entire sections
- API‑level resolution
  - Pass `personi` audience tags to APIs; responses are resolved to the best matching variant
  - Works for `content.api.items`, `content.api.item`, and `pages.api.page`
- Smart matching
  - Uses Jaccard similarity on audience tags; falls back to the first active variant when no better match exists
- Non‑destructive
  - Stored data remains unchanged; resolution only transforms the API output
- Variable Replacement
  - Use placeholders like `{{ name }}` in your content and replace them via request variables

## 🚀 Quick Start

### 1) Install

Copy this addon into `addons/Personi/` and ensure Cockpit loads addons (default). No extra configuration required.

### 2) Add a Variants field

When defining a collection/singleton field, choose type `variants` and configure the inner fields that make up each variant’s `data`:

- Field type: `variants`
- Option: `fields` → add any fields (e.g. `heading`, `image`, `cta`)

Your stored value will look like:

```json
{
  "personi:variants": [
    {
      "id": "auto-generated",
      "active": true,
      "label": "Default",
      "data": { "heading": "Welcome" },
      "meta": null,
      "audience": []
    },
    {
      "id": "auto-generated",
      "active": true,
      "label": "Members",
      "data": { "heading": "Welcome back" },
      "meta": null,
      "audience": ["member"]
    }
  ]
}
```

### 3) Use Layout Variants (optional)

In Layout pages, add the component "Layout Variants". It contains a `layout` field of type `variants` so you can define audience‑specific layouts. At API time, Personi unwraps the selected layout automatically.

### 4) Request personalized content

Add the `personi` query parameter when calling Cockpit APIs:

- Comma‑separated string: `?personi=member,premium`
- Repeated parameter or array also works depending on client

You can also send `X-Personi-Audience: member,premium` via HTTP header.

Examples:

```http
GET /api/content/items/blog?token=YOUR_TOKEN&personi=member
GET /api/content/item/blog/ID?token=YOUR_TOKEN&personi=member,premium
GET /api/pages/page/HOME_ID?token=YOUR_TOKEN&personi=mobile

// With header
GET /api/content/items/blog?token=YOUR_TOKEN
X-Personi-Audience: member,premium
```

When `personi` is present, the response is processed and any `personi:variants` node is replaced with the best matching variant’s `data`.

## 🧠 How Matching Works

- Filters out inactive variants
- Computes similarity between the request audience (tags you pass) and each variant’s `audience` using Jaccard similarity: `|intersection| / |union|`
- Picks the variant with the highest similarity
- If no variant has an audience or there’s no better match, falls back to the first active variant

This resolution runs recursively across arrays, so nested structures are also handled.

## 🛠️ Admin Field Details

The `variants` field provides:

- Add/Remove/Reorder variants
- Toggle active state and set an optional label
- Audience tags editor
- Embedded fields renderer to define the variant `data`
- Optional `meta` object for additional notes/config

## ⏱️ Scheduling Variants

Use `meta.schedule` inside a variant to control when it is active. Supported keys (all optional):

- `start`: DateTime string (ISO 8601 recommended)
- `end`: DateTime string (ISO 8601 recommended)
- `timezone` or `tz`: PHP timezone identifier (e.g., `Europe/Berlin`)
- `days`: Allowed days; numbers `0..6` where `0=Sun` or names `sun..sat`/`sunday..saturday`
- `times`: One or more windows for the day:
  - String: `"HH:MM-HH:MM"` (supports overnight, e.g. `"22:00-02:00"`)
  - Object: `{ "from": "HH:MM", "to": "HH:MM" }`

Example: Weekday business hours in Berlin during November 2025

```json
{
  "meta": {
    "schedule": {
      "start": "2025-11-01T00:00:00",
      "end": "2025-11-30T23:59:59",
      "timezone": "Europe/Berlin",
      "days": ["mon", "tue", "wed", "thu", "fri"],
      "times": ["09:00-17:30"]
    }
  }
}
```

Notes:
- If `timezone/tz` is set, schedule evaluation is anchored to that zone.
- If not set, the server timezone is used.
- API clients can provide their timezone offset via `tz_offset` to ensure correct day/time evaluation across timezones (see below).

Fallback shorthand (also supported if you don’t want a nested object):

```json
{
  "meta": {
    "start": "2025-11-01T00:00:00",
    "end": "2025-11-30T23:59:59"
  }
}
```

## 🕒 Client Time Offset (tz_offset)

API clients can pass their timezone offset (relative to UTC) to have scheduling evaluated correctly for the user’s local time.

- Query param: `tz_offset=120` (minutes) or `tz_offset=+02:00`
- Header: `X-Personi-TZ-Offset: +02:00`

Rules and behavior:
- `tz_offset` accepts either an integer (minutes) or a string in the form `±HH:MM`.
- When provided, Personi derives the client-local “now” by adding the offset to UTC and evaluates day/time windows against that local time.
- If a variant defines `meta.schedule.timezone|tz`, that schedule timezone may be used when no offset is provided.
- This does not change stored data; it only affects resolution for the response.

## 🔤 Variable Replacement

You can use placeholders in your content (strings) that will be replaced by values provided in the request context.

- Syntax: `{{ name }}`, `{{ user.location.city }}` (supports dot notation) or `{{ name:Guest }}` (supports default value)
- Query param: `?personi_vars[name]=Artur` or `?personi_vars={"name":"Artur"}`
- Header: `X-Personi-Vars: {"name":"Artur"}`

Example:
If you have a variant data `{"title": "Hello {{ name }}"}` and request with `?personi_vars[name]=Artur`, the response will be `{"title": "Hello Artur"}`.

## 💻 Programmatic Use

You can resolve arrays programmatically in custom code by using the helper:

```php
// $app is the Cockpit/Lime app instance
$audience = ['member', 'premium'];
$data = $app->helper('personi')->process($data, $audience);

// With client time offset context
$ctx = ['tzoffset' => 120]; // minutes relative to UTC
$data = $app->helper('personi')->process($data, $audience, $ctx);
```

The addon automatically hooks API events (`content.api.items`, `content.api.item`, `pages.api.page`) when the `personi` parameter is present.

## ⚙️ Configuration

No required configuration. The addon registers:

- Helper: `Personi\\Helper\\Personi`
- Field component: `variants` (registered as `field-variants` in the admin)
- Layout component: `layoutVariants`

## 📌 Notes & Limitations

- Resolution only happens when the `personi` query parameter is present. Without it, APIs return the raw variant structure.
- Audience tags are matched as provided; normalize consistently on the client (e.g. lowercase).
- If multiple variants tie with the same similarity, the first encountered wins.
- If you use `tz_offset` in public APIs, consider disabling caching or adding cache vary rules for such requests.

## 📄 License

Commercial license. See [LICENSE.md](LICENSE.md).
