# SmartLinks Documentation — Full Corpus Source: https://docs.smartlinks.app Generated: 2026-06-13T08:48:27.573Z Documents: 396 --- # Configuring your mobile portal Source: https://docs.smartlinks.app/docs/guides/portal-framework Configure your mobile portal experience # Portal Framework — User Guide Welcome to the Portal Framework! This guide walks you through everything you need to know to create, customise, and manage your branded digital portal — no coding required. --- ## What is Portal Framework? Portal Framework is a visual layout builder that lets you create mobile-first digital experiences for your brand. Think of it as a website builder specifically designed for product engagement — connecting your physical products to digital content, loyalty programmes, authentication, and more. Each portal is tied to a **collection** (your brand or product line) and can include multiple **apps** (mini-tools like warranty registration, competitions, product info, etc.) arranged however you like. --- ## Getting Started ### 1. Pick a Starting Template When you first open the builder, you'll see the **Welcome Page** with several pre-built templates: | Template | Best For | |----------|----------| | **Fan Connect (Sports)** | Sports clubs, teams, fan engagement | | **Fan Connect (Music)** | Artists, labels, music experiences | | **Smart Docent** | Museums, galleries, cultural institutions | | **Generic** | General-purpose — works for any brand | Click a template card to see a live preview on the right, then click **Get Started** to begin customising. > **Tip:** Don't worry about choosing the "perfect" template — everything is fully customisable after you start. You can also switch templates later from **Settings → Advanced**. ### 2. Quick Walkthrough After selecting a template, a short guided walkthrough helps you set the basics: - **Portal title** and logo - **Side menu** items - **Bottom tabs** arrangement You can skip any step and come back to it later. ### 3. The Builder Interface Once you're in the full builder, you'll see: - **Left sidebar** — Five sections: Appearance, Navigation, Actions & Tools, Content, Settings - **Centre panel** — Configuration options for the selected section - **Right panel** — Live phone preview showing your changes in real time --- ## Sections Overview ### 🎨 Appearance Control how your portal looks. #### Header - **Enable/disable** the top header bar - Set a **title** (defaults to your collection name) - Toggle the **logo** — use your collection's default or upload a custom one - Configure the **back button** (auto, always visible, or hidden) - Show or hide **action buttons** in the header #### Colours - Set your brand **colour palette** — primary, secondary, accent, background, foreground, muted, and border colours - Configure colours independently for **light mode** and **dark mode** - Choose a **colour mode strategy**: - **Auto** — inherits from the parent app - **Always Light** or **Always Dark** — forces a specific mode - **User Choice** — adds a toggle so visitors can switch #### Typography - Pick custom **heading** and **body fonts** from a curated library - Fonts are loaded automatically — no manual setup needed #### Loading - Customise the **loading screen** appearance - Upload a custom **loading logo** - Set a **loading background colour** #### Background - Add a **background image** to the portal - Adjust **opacity** and choose a **size mode** (cover, contain, etc.) --- ### 🧭 Navigation Set up how users move around your portal. There are five navigation zones: #### Side Menu - **Enable/disable** the slide-out menu - Set the **position** (left or right) and **style** (overlay or push) - Add a **menu header** with title and optional logo - **Add, remove, and reorder items** — each item can link to an app, a custom URL, or be a visual divider - Toggle **icons** on or off #### Top Tabs - Horizontal tabs below the header - Choose a **style**: underline, pills, or buttons - Add tabs linked to specific apps #### Bottom Tabs - Mobile-style tab bar at the bottom of the screen - Toggle **labels** on or off - Add up to 5 tabs (varies by template) #### Quick Actions - A **three-dot menu** (⋮) in the header - Add apps, links, or built-in actions (like QR scanner) - Great for less-frequently-used features #### Floating Action Button (FAB) - A circular button that floats above the content - Expands into a **speed dial** with multiple actions - Configure its **position** (bottom-left, right, or centre) - Choose **solid colour** or **gradient** style - Customise the **icon** and individual action items - Optionally **hide on desktop** or collapse items into the header menu --- ### ⚡ Actions & Tools Enable built-in tools and integrations: #### Account Management - Toggle user **login/logout** functionality - Choose how accounts appear: - **Header style**: avatar, login button, or hidden - **Side menu**: show avatar, name, email, logout button - **Name format**: display name, first name, full name, or email - Customise the **login button text** - Show a **login prompt** in the side menu for logged-out users #### QR Code Scanner - Camera-based scanner for navigating to products and proofs - Appears as an icon in the header #### Language Selector - Globe icon for switching languages - Available languages come from your collection's locale settings #### AI Assistant - AI-powered chat assistant for your portal visitors - Configure a **persona**: name, avatar, and welcome message - Write custom **instructions** to control the AI's behaviour and tone - Choose a **position**: bottom sheet or side panel - Enable **voice input** and **text-to-speech** - Select a **voice** for spoken responses - Works with your collection's existing content automatically #### NFC Scanner - Near Field Communication tag reader - For use with NFC-enabled physical products --- ### 🏠 Content Configure what visitors see when they first arrive. #### Collection Homepage Choose what loads as the landing page: | Mode | Behaviour | |------|-----------| | **Auto** | Shows "My Items" if logged in, otherwise shows all products | | **My Items** | Always shows the user's owned items (requires login) | | **All Products** | Always shows the full product catalogue | | **Custom App** | Loads a specific app as the homepage | You can also set **different homepages based on login state** — e.g., show a welcome app to logged-out visitors and "My Items" to logged-in users. #### Product Catalogue Customise how the product listing looks: - **View modes**: grid, list, or compact — and which modes visitors can switch between - **Card size**: small, medium, or large - **Search bar**: enable/disable, and set a minimum product count before search appears - **Sorting**: by name or date, ascending or descending - **Title**: show/hide and override the default heading --- ### ⚙️ Settings Portal-level configuration options: #### PWA / Install Make your portal **installable** as a native-feeling app: - Set an **app name** and **short name** (for the home screen) - Add a **description** - Choose **display mode** (standalone, fullscreen, minimal UI) - Set **orientation** (any, portrait, landscape) - Upload **app icons** and a **splash logo** - Optionally override the **start URL** #### Sharing & Social Control how links look when shared on social media: - Set **OG title**, **description**, and **image** (1200×630px recommended) - Preview how your link will appear - Configure **Twitter card type** and **handle** - Customise the **share text template** with variables like `{{url}}`, `{{title}}`, `{{collection}}` #### Onboarding Create a guided first-time experience for new visitors: - **Enable/disable** the onboarding flow - Add **steps** with title, description, and optional image - Choose a **style**: slides, cards, or checklist - Show **progress indicators** and **skip button** - **Re-show** to returning visitors if needed #### Analytics Integrate tracking and analytics: - **Google Analytics** — paste your GA4 measurement ID - **Facebook Pixel** — paste your Pixel ID - Additional tracking can be configured via the SmartLinks platform #### Advanced - **Switch template** — apply a completely different starting template (⚠️ this resets your customisations) - **Export/import** configuration as JSON (coming soon) --- ## Saving and Publishing ### Save Click the **Save** button in the top-right toolbar to store your configuration. This saves to the SmartLinks platform and is immediately available to your portal visitors. ### Reset Click **Reset** to discard all customisations and start fresh with a new template. You'll be taken back to the welcome page to pick a new starting layout. > ⚠️ **Reset is permanent** — a confirmation dialog will appear before any changes are discarded. ### Preview Click **Open** in the toolbar to view your live portal in a new browser tab. ### Toggle Preview Click the **eye icon** to show or hide the phone preview panel while you work. --- ## Tips & Best Practices 1. **Start simple** — enable only what you need. You can always add features later. 2. **Mobile first** — most of your visitors will be on phones. Use the phone preview to check everything fits. 3. **Consistent branding** — set your brand colours once in Appearance → Colours, and they'll apply everywhere. 4. **Less is more for tabs** — 3–5 bottom tabs is ideal. Too many creates confusion. 5. **Use the side menu** for secondary navigation — pages people visit occasionally. 6. **Quick actions** are perfect for utility features like QR scanning or language switching. 7. **Test with real content** — the preview shows a mockup. Use the "Open" button to see your portal with real data. --- ## FAQ **Q: Can I change the template after I've started customising?** A: Yes! Go to Settings → Advanced and pick a new template. Be aware this will replace your current layout configuration. **Q: What happens if I don't set custom colours?** A: The portal uses sensible defaults that work well in both light and dark modes. Your collection's brand colour (if set in SmartLinks) will be used as the primary colour. **Q: How do I add a new app to my portal?** A: Apps are added to your collection via the SmartLinks platform. Once added there, they'll appear as options when adding items to menus, tabs, or the homepage. **Q: Can visitors install my portal as an app on their phone?** A: Yes! Enable the PWA settings under Settings → PWA / Install. Visitors will be prompted to "Add to Home Screen" on supported devices. **Q: How do I set up the AI assistant?** A: Go to Actions & Tools → AI Assistant, enable it, and configure a persona name and welcome message. The AI automatically has access to your collection's content — no extra setup needed. **Q: What's the difference between the side menu and bottom tabs?** A: Bottom tabs are always visible and best for primary navigation (2–5 items). The side menu slides out on demand and is better for secondary pages, settings, and links. **Q: Can I have different layouts for different products?** A: The portal layout applies at the collection level. Individual products show their own apps and content within the portal's navigation structure. --- ## Need Help? If you have questions or run into issues, reach out to your SmartLinks account manager or visit the SmartLinks support portal. --- # Shopify Private App Setup Source: https://docs.smartlinks.app/docs/guides/shopify-private-app-setup

🛍️ Shopify Private App Setup

Follow these steps to create a private app in your Shopify store and get the API credentials needed for the referral system.


1. Access Private Apps in Shopify Admin

  1. Log into your Shopify Admin dashboard
  2. Go to Settings (bottom left)
  3. Click on Apps and sales channels
  4. Click Develop apps at the bottom
  5. If this is your first time, click Allow custom app development

Note: You must be the store owner or have the “Develop apps” permission to create private apps.


2. Create Your Private App

  1. Click Create an app
  2. Enter an app name: referal app
  3. Enter your email as the App developer
  4. Click Create app

3. Configure API Permissions

Click on Configure Admin API scopes and enable these permissions:

✅ Required Permissions

Scope Description
read_discounts View discount codes
write_discounts Create discount codes
read_orders Track referral purchases
read_products Access product information

⚙️ Optional (for advanced features)

Scope Description
read_customers Customer analytics
write_script_tags Tracking scripts

Important: Only grant the minimum permissions needed. You can always add more later if required.


4. Install App and Get API Credentials

  1. Click Save to save your API permissions
  2. Click Install app (top right)
  3. Review the permissions and click Install
  4. You’ll now see the Admin API access token
  5. Click Reveal token once and copy it immediately
Security Warning:
The API token will only be shown once.
Store it securely and never share it publicly.
If you lose it, you’ll need to uninstall and reinstall the app.

5. Configure in Referral System

  1. Return to your Referral System Admin Panel
  2. Go to Shopify Configuration
  3. Enter your store URL (e.g. your-store.myshopify.com)
  4. Paste the Admin API access token
  5. Test the connection to verify everything works

🎉 You’re all set! Your referral system can now create discount codes and track purchases.


Troubleshooting

Can't find "Develop apps"?

You need to be the store owner or have developer permissions. Contact your store owner to enable custom app development.

API calls failing?

Check that you've granted the correct permissions and that your store URL is in the format your-store.myshopify.com (without https://).

Lost your API token?

You'll need to uninstall the app and reinstall it to generate a new token. The old token will be invalidated.


Additional Resources

--- # How to Create a New Product Source: https://docs.smartlinks.app/docs/guides/how-to-create-a-new-product Learn how to add a new product to your Smartlinks account, including details like name, description, GTIN, and images. This guide will walk you through the process of adding a new product item in the Smartlinks platform. You will learn how to fill out the product creation form, including essential details like product name, identifiers, and imagery. ## Step 1: Start Creating a New Product After logging in, you will land on the **All Items** page. This page displays all of your existing products as cards. - **Action:** Locate the card titled "Add a New Product" and click the **CREATE** button at the bottom of it. - **You should see:** The "Create Item" form will load, ready for you to enter the new product's details. ## Step 2: Enter Basic Product Details The first section of the form is for the product's basic setup information. - **Action:** 1. Click into the **Name (required)** field and type the name of your product (e.g., "Zumo 1L"). 2. Click into the **Description** field and type a brief description (e.g., "sparkling water"). - **You should see:** The text you type will appear in the corresponding fields. ## Step 3: Add Product Identifiers Next, add your internal and global product identifiers. - **Action:** 1. Enter your internal stock-keeping unit in the **Internal SKU** field. 2. Enter the product's Global Trade Item Number in the **GTIN** field. 3. If you are the brand owner of this GTIN, click the **Owner/Master** toggle to enable it. - **You should see:** The identifiers will appear in their fields. - **Note:** The system will automatically check if the GTIN is valid. An error message like "Not a valid GS1 GTIN" will appear if the number is incorrect. ## Step 4: Add Product Images and Links You can add a primary header image, additional images, and a purchase link. - **Action:** 1. Click the **Header Image** field. This will open your computer's file browser. Select your desired product image and click "Open". 2. To add more images, click the **ADD EXTRA IMAGE** button and repeat the process. 3. (Optional) If you want to link directly to a sales page, enter the URL in the **Purchase Link** field. - **You should see:** The filename of your uploaded image(s) will appear in the respective image fields. ## Step 5: Finalize and Create the Product Once you have filled in all the necessary information, you can create the item. - **Action:** Scroll to the bottom of the page and click the blue **CREATE** button. - **You should see:** The system will process the information and create the new product. It will then be visible on your "All Items" dashboard. --- ### Conclusion You have successfully created a new product item in the Smartlinks platform. This new item now exists in your account with its name, description, unique identifiers, and product images. --- # Dynamic Fields Source: https://docs.smartlinks.app/docs/guides/dynamic-fields # Liquid Templates in SmartLinks Liquid is a templating language that allows you to dynamically insert data into text content. SmartLinks uses Liquid Templates in various APIs—such as email templates, notification messages, and dynamic content—to personalize communications with real-time data from your collections, products, proofs, and users. --- ## What are Liquid Templates? Liquid is an open-source template language created by Shopify. It uses a simple syntax with two main components: - **Output tags** `{{ }}` — Insert dynamic values - **Logic tags** `{% %}` — Control flow (if/else, loops, etc.) ### Basic Example ```liquid Hello {{ contact.name }}, Thank you for registering your {{ product.name }}! Your proof ID is: {{ proof.id }} {% if proof.claimed %} This item was claimed on {{ proof.claimedAt | date: "%B %d, %Y" }}. {% endif %} ``` --- ## Core Data Objects SmartLinks provides several core objects that can be accessed in Liquid Templates. The available objects depend on the context (e.g., a proof-level template has access to `proof`, `product`, and `collection`). --- ### Collection A **Collection** represents a top-level business, brand, or organization. All products belong to a collection. | Field | Type | Description | |-------|------|-------------| | `collection.id` | string | Unique identifier | | `collection.name` | string | Display name of the collection | | `collection.description` | string | Description text | | `collection.slug` | string | URL-friendly identifier | | `collection.logoUrl` | string | URL to the collection's logo image | | `collection.websiteUrl` | string | Primary website URL | | `collection.metadata` | object | Custom key-value metadata | | `collection.createdAt` | datetime | When the collection was created | | `collection.updatedAt` | datetime | When the collection was last updated | #### Example Usage ```liquid Welcome to {{ collection.name }}! {% if collection.websiteUrl %} Visit us at {{ collection.websiteUrl }} {% endif %} {{ collection.name }} logo ``` --- ### Product A **Product** represents a type or definition of a physical or digital item. Products belong to a collection and can have many proofs (instances). | Field | Type | Description | |-------|------|-------------| | `product.id` | string | Unique identifier | | `product.name` | string | Product name | | `product.description` | string | Product description | | `product.sku` | string | Stock keeping unit | | `product.slug` | string | URL-friendly identifier | | `product.imageUrl` | string | Primary product image URL | | `product.images` | array | Array of image URLs | | `product.category` | string | Product category | | `product.tags` | array | Array of tag strings | | `product.metadata` | object | Custom key-value metadata | | `product.createdAt` | datetime | When the product was created | | `product.updatedAt` | datetime | When the product was last updated | #### Example Usage ```liquid Your {{ product.name }} (SKU: {{ product.sku }}) {{ product.description }} {% if product.tags.size > 0 %} Tags: {{ product.tags | join: ", " }} {% endif %} {% for image in product.images %} {{ product.name }} {% endfor %} ``` --- ### Proof A **Proof** is a specific instance of a product—think of it as a unique digital certificate for a physical item. Proofs can be claimed by users and carry ownership information. | Field | Type | Description | |-------|------|-------------| | `proof.id` | string | Unique identifier | | `proof.serialNumber` | string | Human-readable serial number | | `proof.claimed` | boolean | Whether the proof has been claimed | | `proof.claimedAt` | datetime | When the proof was claimed | | `proof.claimedBy` | string | User ID of the claimer | | `proof.status` | string | Current status (e.g., "active", "transferred") | | `proof.nfcTagId` | string | Associated NFC tag ID (if applicable) | | `proof.qrCode` | string | QR code identifier | | `proof.shortCode` | string | Short code for easy lookup | | `proof.metadata` | object | Custom key-value metadata | | `proof.createdAt` | datetime | When the proof was created | | `proof.updatedAt` | datetime | When the proof was last updated | #### Example Usage ```liquid Proof of Authenticity Serial Number: {{ proof.serialNumber }} Status: {{ proof.status }} {% if proof.claimed %} Claimed on: {{ proof.claimedAt | date: "%B %d, %Y at %H:%M" }} {% else %} This item has not been claimed yet. {% endif %} {% if proof.metadata.warrantyExpiry %} Warranty expires: {{ proof.metadata.warrantyExpiry | date: "%B %d, %Y" }} {% endif %} ``` --- ### Contact A **Contact** represents a customer or user in the system. Contacts are associated with a collection and can own multiple proofs. | Field | Type | Description | |-------|------|-------------| | `contact.id` | string | Unique identifier | | `contact.email` | string | Email address | | `contact.name` | string | Full name | | `contact.firstName` | string | First name | | `contact.lastName` | string | Last name | | `contact.phone` | string | Phone number | | `contact.locale` | string | Preferred language/locale (e.g., "en", "de") | | `contact.timezone` | string | Preferred timezone | | `contact.avatarUrl` | string | Profile picture URL | | `contact.metadata` | object | Custom key-value metadata | | `contact.tags` | array | Array of tag strings for segmentation | | `contact.createdAt` | datetime | When the contact was created | | `contact.updatedAt` | datetime | When the contact was last updated | | `contact.lastSeenAt` | datetime | Last activity timestamp | #### Example Usage ```liquid Hi {{ contact.firstName | default: contact.name | default: "there" }}, {% if contact.locale == "de" %} Willkommen! {% elsif contact.locale == "fr" %} Bienvenue! {% else %} Welcome! {% endif %} {% if contact.phone %} We'll send updates to {{ contact.phone }}. {% endif %} ``` --- ### User (Account) A **User** represents an authenticated account in the system. This is typically the logged-in user performing an action. | Field | Type | Description | |-------|------|-------------| | `user.id` | string | Unique identifier | | `user.email` | string | Email address | | `user.name` | string | Display name | | `user.admin` | boolean | Whether user has admin privileges | | `user.avatarUrl` | string | Profile picture URL | | `user.createdAt` | datetime | Account creation date | #### Example Usage ```liquid Logged in as: {{ user.name }} ({{ user.email }}) {% if user.admin %} 🔐 You have administrator access. {% endif %} ``` --- ### Attestation An **Attestation** is flexible data attached to a specific proof. It's used to store additional information like warranty registrations, tasting notes, service records, etc. | Field | Type | Description | |-------|------|-------------| | `attestation.id` | string | Unique identifier | | `attestation.type` | string | Attestation type (app-defined) | | `attestation.data` | object | The attestation payload (varies by type) | | `attestation.createdBy` | string | User ID who created it | | `attestation.createdAt` | datetime | When the attestation was created | | `attestation.updatedAt` | datetime | When the attestation was last updated | #### Example Usage ```liquid {% if attestation.type == "warranty_registration" %} Warranty Registration Details: - Registered: {{ attestation.createdAt | date: "%B %d, %Y" }} - Purchase Date: {{ attestation.data.purchaseDate }} - Store: {{ attestation.data.storeName }} {% endif %} {% if attestation.type == "tasting_note" %} 🍷 Tasting Note by {{ attestation.data.author }}: "{{ attestation.data.notes }}" Rating: {{ attestation.data.rating }}/5 {% endif %} ``` --- ## Liquid Filters Liquid provides built-in filters to transform data. Common filters include: ### Text Filters | Filter | Description | Example | |--------|-------------|---------| | `upcase` | Convert to uppercase | `{{ product.name \| upcase }}` | | `downcase` | Convert to lowercase | `{{ product.name \| downcase }}` | | `capitalize` | Capitalize first letter | `{{ contact.name \| capitalize }}` | | `truncate` | Limit string length | `{{ product.description \| truncate: 100 }}` | | `strip_html` | Remove HTML tags | `{{ content \| strip_html }}` | | `escape` | HTML escape special chars | `{{ user_input \| escape }}` | | `default` | Fallback value if empty | `{{ contact.name \| default: "Customer" }}` | ### Date Filters | Filter | Description | Example | |--------|-------------|---------| | `date` | Format a date | `{{ proof.claimedAt \| date: "%B %d, %Y" }}` | Common date formats: - `%B %d, %Y` → January 15, 2025 - `%Y-%m-%d` → 2025-01-15 - `%d/%m/%Y` → 15/01/2025 - `%H:%M` → 14:30 ### Array Filters | Filter | Description | Example | |--------|-------------|---------| | `join` | Join array elements | `{{ product.tags \| join: ", " }}` | | `first` | Get first element | `{{ product.images \| first }}` | | `last` | Get last element | `{{ product.images \| last }}` | | `size` | Get array length | `{{ product.tags.size }}` | | `sort` | Sort array | `{{ items \| sort: "name" }}` | ### Number Filters | Filter | Description | Example | |--------|-------------|---------| | `plus` | Add | `{{ count \| plus: 1 }}` | | `minus` | Subtract | `{{ total \| minus: discount }}` | | `times` | Multiply | `{{ price \| times: quantity }}` | | `divided_by` | Divide | `{{ total \| divided_by: 2 }}` | | `round` | Round number | `{{ average \| round: 2 }}` | --- ## Control Flow ### Conditionals ```liquid {% if proof.claimed %} This item is claimed. {% elsif proof.status == "pending" %} Claim pending verification. {% else %} Available to claim. {% endif %} {% unless contact.email %} No email on file. {% endunless %} ``` ### Operators | Operator | Description | |----------|-------------| | `==` | Equals | | `!=` | Not equals | | `>` | Greater than | | `<` | Less than | | `>=` | Greater than or equal | | `<=` | Less than or equal | | `or` | Logical OR | | `and` | Logical AND | | `contains` | String/array contains | ```liquid {% if product.tags contains "premium" %} 🌟 Premium Product {% endif %} {% if contact.email and proof.claimed %} Send confirmation to {{ contact.email }} {% endif %} ``` ### Loops ```liquid {% for tag in product.tags %} {{ tag }} {% endfor %} {% for image in product.images limit: 3 %} {{ product.name }} image {{ forloop.index }} {% endfor %} ``` Loop variables: - `forloop.index` — Current iteration (1-indexed) - `forloop.index0` — Current iteration (0-indexed) - `forloop.first` — Is this the first iteration? - `forloop.last` — Is this the last iteration? - `forloop.length` — Total number of iterations --- ## Common Use Cases ### Email Templates ```liquid Subject: Your {{ product.name }} has been registered! Hi {{ contact.firstName | default: "there" }}, Great news! Your {{ product.name }} (Serial: {{ proof.serialNumber }}) has been successfully registered to your account. {% if product.metadata.warrantyYears %} Your warranty is valid for {{ product.metadata.warrantyYears }} years from the date of purchase. {% endif %} If you have any questions, please contact {{ collection.name }} support. Best regards, The {{ collection.name }} Team ``` ### Notification Messages ```liquid 🎉 {{ contact.firstName }}, your {{ product.name }} is now verified! Proof ID: {{ proof.shortCode }} ``` ### Dynamic Content Blocks ```liquid {% if proof.metadata.tier == "gold" %}
As a Gold member, you get exclusive access to...
{% elsif proof.metadata.tier == "silver" %}
Your Silver membership includes...
{% endif %} ``` ### Multilingual Content ```liquid {% case contact.locale %} {% when "de" %} Vielen Dank für Ihre Registrierung! {% when "fr" %} Merci pour votre inscription! {% when "es" %} ¡Gracias por registrarte! {% else %} Thank you for registering! {% endcase %} ``` --- ## Accessing Nested Data Use dot notation to access nested fields in metadata or data objects: ```liquid {{ product.metadata.manufacturer }} {{ attestation.data.warranty.expiryDate }} {{ collection.metadata.social.twitter }} ``` For dynamic keys, you may need to use bracket notation (if supported): ```liquid {{ product.metadata["custom-field"] }} ``` --- ## Best Practices 1. **Always use `default` filter** for optional fields to avoid blank output: ```liquid {{ contact.name | default: "Valued Customer" }} ``` 2. **Escape user-generated content** when outputting as HTML: ```liquid {{ attestation.data.userNotes | escape }} ``` 3. **Check for existence** before accessing nested data: ```liquid {% if proof.metadata.warranty %} Warranty: {{ proof.metadata.warranty.type }} {% endif %} ``` 4. **Use meaningful fallbacks** for a better user experience: ```liquid Hi {{ contact.firstName | default: contact.name | default: "there" }}, ``` 5. **Format dates appropriately** for the user's locale: ```liquid {{ proof.claimedAt | date: "%d %B %Y" }} ``` --- ## API Context Different APIs provide different objects in the Liquid context: | API / Feature | Available Objects | |---------------|-------------------| | Email Templates | `collection`, `product`, `proof`, `contact`, `attestation` | | Push Notifications | `collection`, `product`, `proof`, `contact` | | SMS Messages | `collection`, `product`, `proof`, `contact` | | Wallet Passes | `collection`, `product`, `proof`, `contact` | | Journey Actions | `collection`, `product`, `proof`, `contact`, `event` | | Broadcast Campaigns | `collection`, `contact`, `segment` | Check the specific API documentation for the exact objects available in each context. --- ## Further Resources - [Liquid Template Language Documentation](https://shopify.github.io/liquid/) - [Liquid for Designers](https://github.com/Shopify/liquid/wiki/Liquid-for-Designers) - SmartLinks Template API Reference --- # How to Set Up Product Ingredients and Allergens Source: https://docs.smartlinks.app/docs/guides/how-to-set-up-product-ingredients-and-allergens Learn how to add an ingredient list, use the platform's AI to parse it, and configure specific allergen and dietary preference information for your products. This guide will show you how to set up ingredients, allergens, and dietary information for your products using the Smartlinks platform. You will learn how to enter an ingredient list, use the AI Parse feature to structure it, and configure specific allergen and dietary labels. ## Step 1: Open a Product First, navigate to the product you wish to edit. - From the main dashboard, ensure you are in the **Items** section, visible in the left-hand navigation menu. - You will see your products displayed as cards in the main content area. Click the **OPEN** button on the card for the product you want to configure. ## Step 2: Navigate to the Ingredients Module Once you have opened a product, you will be taken to its specific dashboard. - On the product dashboard, locate the module card labeled **Ingredients**. - Click anywhere on the **Ingredients** card to open the ingredients configuration page. ## Step 3: Add and Parse the Ingredient Statement The "Build an ingredient list" page is where you will add and manage all ingredient data. The AI can help structure this information automatically. - In the "Main Ingredients & AI Analysis" section, locate the text box labeled **Ingredients Statement**. - Paste or type your product's full legal ingredient statement into this box. - Click the blue **AI PARSE** button to the right of the text box. - The system will process the text, and a preview window titled "AI Parsed Ingredients Preview" will appear. This shows the structured ingredient list and any dietary suitability tags the AI has detected. You can review this and close the preview by clicking the 'x' in its top-right corner. > **Note:** A warning banner "Run AI Parse to structure & validate this statement (required before save)" may appear. You must complete the AI Parse step before you can save your changes. ## Step 4: Configure Allergens and Warnings Below the main ingredients section, you can manually declare allergens and add warnings. - In the **Named Allergies** section, click on the **Selected Allergies** dropdown menu. - Check the box next to any allergens present in your product (e.g., Eggs, Lupin). Click outside the dropdown to close it. - To add a general nut warning, click the toggle switch next to **Add a general Nut Warning**. This will reveal a text box where you can add specific notes if needed. ## Step 5: Set Dietary & Personal Preference Compatibility Specify dietary attributes to help consumers make informed choices. - Scroll down to the **Dietary & Personal Preference Compatibility** section. - You will see a list of dietary labels like Organic, Vegetarian, Vegan, and Wheat Free. - Click the toggle switch next to each attribute that applies to your product. The toggle will turn blue when enabled. ## Step 6: Save Your Changes Once you have finished configuring all the ingredient information, save your work. - Click the blue **SAVE** button located in the top-right corner of the page. - Your ingredient, allergen, and dietary information is now saved for this product. ### Conclusion You have successfully added and configured the ingredients, allergens, and dietary compatibility for your product. This information will now be structured and available for use across the platform. --- # Authentication Guide Source: https://docs.smartlinks.app/docs/guides/authentication-guide Learn how to authenticate with the Smartlinks API using API keys and bearer tokens # Authentication Guide Welcome to the Smartlinks API authentication guide. This tutorial will walk you through the process of authenticating your API requests. ## Overview The Smartlinks API uses API key authentication. All requests must include your API key in the Authorization header. ## Getting Your API Key 1. Log into your Smartlinks dashboard 2. Navigate to **Settings > API Keys** 3. Click **Generate New API Key** 4. Copy and securely store your API key > **Important:** Keep your API keys secure and never commit them to version control. Use environment variables instead. ## Making Authenticated Requests ### JavaScript/Node.js ```javascript const fetch = require('node-fetch'); const SMARTLINKS_API_KEY = process.env.SMARTLINKS_API_KEY; const BASE_URL = 'https://api.smartlinks.io/v1'; async function makeAuthenticatedRequest() { const response = await fetch(`${BASE_URL}/links`, { method: 'GET', headers: { 'Authorization': `Bearer ${SMARTLINKS_API_KEY}`, 'Content-Type': 'application/json' } }); const data = await response.json(); return data; } makeAuthenticatedRequest() .then(data => console.log(data)) .catch(error => console.error('Error:', error)); ``` ### Python ```python import os import requests SMARTLINKS_API_KEY = os.getenv('SMARTLINKS_API_KEY') BASE_URL = 'https://api.smartlinks.io/v1' headers = { 'Authorization': f'Bearer {SMARTLINKS_API_KEY}', 'Content-Type': 'application/json' } response = requests.get(f'{BASE_URL}/links', headers=headers) data = response.json() print(data) ``` ### cURL ```bash curl -X GET "https://api.smartlinks.io/v1/links" \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" ``` ## Environment Variables Always store your API keys in environment variables: ### .env file ```env SMARTLINKS_API_KEY=your_api_key_here ``` ### Loading in Node.js ```javascript require('dotenv').config(); const apiKey = process.env.SMARTLINKS_API_KEY; ``` ## Error Handling Handle authentication errors gracefully: ```javascript async function authenticatedFetch(endpoint) { try { const response = await fetch(`${BASE_URL}${endpoint}`, { headers: { 'Authorization': `Bearer ${SMARTLINKS_API_KEY}` } }); if (response.status === 401) { throw new Error('Invalid API key'); } if (response.status === 403) { throw new Error('Access forbidden'); } return await response.json(); } catch (error) { console.error('Authentication error:', error); throw error; } } ``` ## Best Practices - ✅ Store API keys in environment variables - ✅ Use HTTPS for all API requests - ✅ Rotate API keys periodically - ✅ Implement rate limiting on your end - ❌ Never expose API keys in client-side code - ❌ Never commit API keys to version control ## Next Steps - [Creating Your First Link](/docs/guides/creating-links) - [Setting Up Webhooks](/docs/guides/webhook-setup) - [API Reference](/docs/api) --- # Creating Links with the API Source: https://docs.smartlinks.app/docs/guides/creating-links Complete guide to creating and managing short links using the Smartlinks API # Creating Links with the API Learn how to create and manage short links programmatically using the Smartlinks API. ## Basic Link Creation The simplest way to create a link is to provide a destination URL: ### JavaScript ```javascript async function createLink(destinationUrl) { const response = await fetch('https://api.smartlinks.io/v1/links', { method: 'POST', headers: { 'Authorization': `Bearer ${SMARTLINKS_API_KEY}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ destination_url: destinationUrl }) }); const link = await response.json(); console.log('Created link:', link.short_url); return link; } // Usage createLink('https://example.com/my-long-url') .then(link => console.log('Short URL:', link.short_url)); ``` ### Python ```python import requests import os def create_link(destination_url): url = 'https://api.smartlinks.io/v1/links' headers = { 'Authorization': f'Bearer {os.getenv("SMARTLINKS_API_KEY")}', 'Content-Type': 'application/json' } payload = { 'destination_url': destination_url } response = requests.post(url, json=payload, headers=headers) link = response.json() print(f'Created link: {link["short_url"]}') return link # Usage link = create_link('https://example.com/my-long-url') print(f'Short URL: {link["short_url"]}') ``` ## Custom Short Links Create branded short links with custom slugs: ```javascript async function createCustomLink(destinationUrl, customSlug) { const response = await fetch('https://api.smartlinks.io/v1/links', { method: 'POST', headers: { 'Authorization': `Bearer ${SMARTLINKS_API_KEY}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ destination_url: destinationUrl, custom_slug: customSlug, title: 'My Custom Link', tags: ['marketing', 'campaign-2024'] }) }); return await response.json(); } // Usage createCustomLink('https://example.com/product', 'summer-sale'); // Creates: https://smrt.lnk/summer-sale ``` ## Link with Expiration Create temporary links that expire after a certain time: ```javascript async function createExpiringLink(destinationUrl, expiresInDays = 7) { const expiresAt = new Date(); expiresAt.setDate(expiresAt.getDate() + expiresInDays); const response = await fetch('https://api.smartlinks.io/v1/links', { method: 'POST', headers: { 'Authorization': `Bearer ${SMARTLINKS_API_KEY}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ destination_url: destinationUrl, expires_at: expiresAt.toISOString(), title: 'Limited Time Offer' }) }); return await response.json(); } ``` ## Bulk Link Creation Create multiple links efficiently: ```javascript async function createBulkLinks(urls) { const promises = urls.map(url => fetch('https://api.smartlinks.io/v1/links', { method: 'POST', headers: { 'Authorization': `Bearer ${SMARTLINKS_API_KEY}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ destination_url: url }) }).then(res => res.json()) ); const links = await Promise.all(promises); return links; } // Usage const urls = [ 'https://example.com/page1', 'https://example.com/page2', 'https://example.com/page3' ]; createBulkLinks(urls) .then(links => console.log('Created', links.length, 'links')); ``` ## Updating Links Modify existing links: ```javascript async function updateLink(linkId, updates) { const response = await fetch(`https://api.smartlinks.io/v1/links/${linkId}`, { method: 'PATCH', headers: { 'Authorization': `Bearer ${SMARTLINKS_API_KEY}`, 'Content-Type': 'application/json' }, body: JSON.stringify(updates) }); return await response.json(); } // Update destination URL updateLink('link_123', { destination_url: 'https://example.com/new-destination', title: 'Updated Title' }); ``` ## Error Handling Always implement proper error handling: ```javascript async function createLinkWithErrorHandling(destinationUrl) { try { const response = await fetch('https://api.smartlinks.io/v1/links', { method: 'POST', headers: { 'Authorization': `Bearer ${SMARTLINKS_API_KEY}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ destination_url: destinationUrl }) }); if (!response.ok) { const error = await response.json(); throw new Error(error.message || 'Failed to create link'); } return await response.json(); } catch (error) { console.error('Error creating link:', error.message); throw error; } } ``` ## Next Steps - [Setting Up Webhooks](/docs/guides/webhook-setup) - [Link Analytics](/docs/guides/analytics) - [API Reference](/docs/api) --- # Webhook Setup Guide Source: https://docs.smartlinks.app/docs/guides/webhook-setup Configure webhooks to receive real-time notifications for link events # Webhook Setup Guide Webhooks allow you to receive real-time notifications when events occur in your Smartlinks account. ## Overview Smartlinks can send webhook events to your server when: - A link is clicked - A new link is created - A link is updated or deleted - Analytics milestones are reached ## Creating a Webhook ### Step 1: Set Up Your Endpoint Create an endpoint on your server to receive webhook events: ```javascript // Express.js example const express = require('express'); const app = express(); app.post('/webhooks/smartlinks', express.json(), (req, res) => { const event = req.body; console.log('Received webhook:', event.type); console.log('Data:', event.data); // Process the event handleWebhookEvent(event); // Acknowledge receipt res.status(200).json({ received: true }); }); app.listen(3000, () => { console.log('Webhook server running on port 3000'); }); ``` ### Step 2: Register the Webhook Register your webhook endpoint with Smartlinks: ```javascript async function registerWebhook(url, events) { const response = await fetch('https://api.smartlinks.io/v1/webhooks', { method: 'POST', headers: { 'Authorization': `Bearer ${SMARTLINKS_API_KEY}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ url: url, events: events, active: true }) }); return await response.json(); } // Register for link.clicked and link.created events registerWebhook('https://your-domain.com/webhooks/smartlinks', [ 'link.clicked', 'link.created', 'link.updated', 'link.deleted' ]); ``` ## Webhook Event Types ### link.clicked Triggered when someone clicks a short link: ```json { "type": "link.clicked", "id": "evt_123abc", "created_at": "2024-01-15T10:30:00Z", "data": { "link_id": "link_456def", "short_url": "https://smrt.lnk/abc123", "destination_url": "https://example.com/page", "click": { "ip_address": "192.168.1.1", "user_agent": "Mozilla/5.0...", "referer": "https://google.com", "country": "US", "city": "New York", "device_type": "desktop" } } } ``` ### link.created Triggered when a new link is created: ```json { "type": "link.created", "id": "evt_789ghi", "created_at": "2024-01-15T10:30:00Z", "data": { "link_id": "link_456def", "short_url": "https://smrt.lnk/abc123", "destination_url": "https://example.com/page", "created_by": "user_123" } } ``` ## Handling Webhooks ### Complete Event Handler ```javascript function handleWebhookEvent(event) { switch (event.type) { case 'link.clicked': handleLinkClick(event.data); break; case 'link.created': handleLinkCreated(event.data); break; case 'link.updated': handleLinkUpdated(event.data); break; case 'link.deleted': handleLinkDeleted(event.data); break; default: console.log('Unhandled event type:', event.type); } } function handleLinkClick(data) { console.log(`Link ${data.link_id} clicked from ${data.click.country}`); // Example: Send to analytics analytics.track('Link Clicked', { linkId: data.link_id, country: data.click.country, device: data.click.device_type }); } function handleLinkCreated(data) { console.log(`New link created: ${data.short_url}`); // Example: Send notification sendNotification(`New short link: ${data.short_url}`); } ``` ## Verifying Webhook Signatures > **Security:** Always verify webhook signatures to ensure requests are from Smartlinks. ```javascript const crypto = require('crypto'); function verifyWebhookSignature(payload, signature, secret) { const expectedSignature = crypto .createHmac('sha256', secret) .update(JSON.stringify(payload)) .digest('hex'); return crypto.timingSafeEqual( Buffer.from(signature), Buffer.from(expectedSignature) ); } // In your webhook handler app.post('/webhooks/smartlinks', express.json(), (req, res) => { const signature = req.headers['x-smartlinks-signature']; const webhookSecret = process.env.WEBHOOK_SECRET; if (!verifyWebhookSignature(req.body, signature, webhookSecret)) { return res.status(401).json({ error: 'Invalid signature' }); } // Process the event handleWebhookEvent(req.body); res.status(200).json({ received: true }); }); ``` ## Testing Webhooks ### Local Testing with ngrok Use ngrok to expose your local server: ```bash # Install ngrok npm install -g ngrok # Expose your local port ngrok http 3000 # Use the ngrok URL when registering webhooks # Example: https://abc123.ngrok.io/webhooks/smartlinks ``` ### Manual Testing Test your webhook endpoint manually: ```bash curl -X POST https://your-domain.com/webhooks/smartlinks \ -H "Content-Type: application/json" \ -H "X-Smartlinks-Signature: test_signature" \ -d '{ "type": "link.clicked", "id": "evt_test", "created_at": "2024-01-15T10:30:00Z", "data": { "link_id": "link_test", "short_url": "https://smrt.lnk/test" } }' ``` ## Error Handling & Retries Smartlinks will retry failed webhook deliveries: - 3 automatic retries with exponential backoff - Maximum retry interval: 1 hour - Webhooks are disabled after 10 consecutive failures ```javascript // Ensure your endpoint responds quickly app.post('/webhooks/smartlinks', express.json(), async (req, res) => { // Acknowledge receipt immediately res.status(200).json({ received: true }); // Process asynchronously processWebhookAsync(req.body).catch(error => { console.error('Error processing webhook:', error); }); }); async function processWebhookAsync(event) { // Your processing logic here await saveToDatabase(event); await triggerNotifications(event); } ``` ## Best Practices - ✅ Respond with 200 status code quickly - ✅ Process events asynchronously - ✅ Verify webhook signatures - ✅ Implement idempotency using event IDs - ✅ Log all webhook events - ❌ Don't perform long-running operations synchronously - ❌ Don't expose your webhook secret ## Next Steps - [API Reference](/docs/api) - [Authentication Guide](/docs/guides/authentication-guide) - [Security Best Practices](/docs/guides/security-best-practices) --- # Security Best Practices Source: https://docs.smartlinks.app/docs/guides/security-best-practices Essential security practices for implementing the Smartlinks API safely and securely # Security Best Practices Follow these security guidelines to keep your Smartlinks integration secure. ## API Key Management ### Never Expose API Keys ❌ **Bad - Exposing in Client Code:** ```javascript // NEVER DO THIS const apiKey = 'sk_live_abc123...'; // Hardcoded API key fetch('https://api.smartlinks.io/v1/links', { headers: { 'Authorization': `Bearer ${apiKey}` } }); ``` ✅ **Good - Server-side Only:** ```javascript // Server-side code (Node.js) const apiKey = process.env.SMARTLINKS_API_KEY; app.post('/api/create-link', async (req, res) => { const response = await fetch('https://api.smartlinks.io/v1/links', { method: 'POST', headers: { 'Authorization': `Bearer ${apiKey}`, 'Content-Type': 'application/json' }, body: JSON.stringify(req.body) }); const data = await response.json(); res.json(data); }); ``` ### Environment Variables Store API keys securely: ```.env # .env file (add to .gitignore!) SMARTLINKS_API_KEY=sk_live_your_key_here SMARTLINKS_WEBHOOK_SECRET=whsec_your_secret_here ``` ```javascript // Load environment variables require('dotenv').config(); const apiKey = process.env.SMARTLINKS_API_KEY; const webhookSecret = process.env.SMARTLINKS_WEBHOOK_SECRET; ``` ### Key Rotation Rotate API keys regularly: ```javascript // Support multiple API keys during rotation const primaryKey = process.env.SMARTLINKS_API_KEY_PRIMARY; const secondaryKey = process.env.SMARTLINKS_API_KEY_SECONDARY; async function makeRequestWithFallback(endpoint, options) { try { // Try primary key first return await makeRequest(endpoint, primaryKey, options); } catch (error) { if (error.status === 401) { // Fallback to secondary key return await makeRequest(endpoint, secondaryKey, options); } throw error; } } ``` ## Rate Limiting Implement client-side rate limiting: ```javascript class RateLimiter { constructor(maxRequests, windowMs) { this.maxRequests = maxRequests; this.windowMs = windowMs; this.requests = []; } async acquire() { const now = Date.now(); this.requests = this.requests.filter( time => now - time < this.windowMs ); if (this.requests.length >= this.maxRequests) { const oldestRequest = this.requests[0]; const waitTime = this.windowMs - (now - oldestRequest); await new Promise(resolve => setTimeout(resolve, waitTime)); return this.acquire(); } this.requests.push(now); } } // Usage: 100 requests per minute const limiter = new RateLimiter(100, 60000); async function createLink(url) { await limiter.acquire(); return fetch('https://api.smartlinks.io/v1/links', { method: 'POST', headers: { 'Authorization': `Bearer ${apiKey}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ destination_url: url }) }); } ``` ## Input Validation Always validate and sanitize user input: ```javascript const validator = require('validator'); function validateLinkInput(input) { const errors = []; // Validate destination URL if (!input.destination_url) { errors.push('Destination URL is required'); } else if (!validator.isURL(input.destination_url, { protocols: ['http', 'https'], require_protocol: true })) { errors.push('Invalid destination URL'); } // Validate custom slug if (input.custom_slug) { if (!/^[a-zA-Z0-9-_]+$/.test(input.custom_slug)) { errors.push('Custom slug can only contain letters, numbers, hyphens, and underscores'); } if (input.custom_slug.length > 50) { errors.push('Custom slug must be 50 characters or less'); } } return errors; } // Usage app.post('/api/create-link', (req, res) => { const errors = validateLinkInput(req.body); if (errors.length > 0) { return res.status(400).json({ errors }); } // Proceed with link creation createLink(req.body) .then(link => res.json(link)) .catch(error => res.status(500).json({ error: error.message })); }); ``` ## Webhook Security ### Verify Signatures Always verify webhook signatures: ```javascript const crypto = require('crypto'); function verifyWebhookSignature(payload, signature, secret) { const hmac = crypto.createHmac('sha256', secret); const digest = hmac.update(JSON.stringify(payload)).digest('hex'); // Use timing-safe comparison return crypto.timingSafeEqual( Buffer.from(signature), Buffer.from(digest) ); } app.post('/webhooks/smartlinks', express.json(), (req, res) => { const signature = req.headers['x-smartlinks-signature']; const secret = process.env.WEBHOOK_SECRET; if (!verifyWebhookSignature(req.body, signature, secret)) { console.error('Invalid webhook signature'); return res.status(401).json({ error: 'Unauthorized' }); } handleWebhook(req.body); res.status(200).json({ received: true }); }); ``` ### Idempotency Handle duplicate webhook events: ```javascript const processedEvents = new Set(); async function handleWebhook(event) { // Check if already processed if (processedEvents.has(event.id)) { console.log('Duplicate event, skipping:', event.id); return; } // Process the event await processEvent(event); // Mark as processed processedEvents.add(event.id); // Clean up old events (older than 24 hours) cleanupOldEvents(); } ``` ## HTTPS Only Always use HTTPS for API requests: ```javascript const BASE_URL = 'https://api.smartlinks.io/v1'; // Always HTTPS // Reject non-HTTPS URLs function ensureHttps(url) { const parsed = new URL(url); if (parsed.protocol !== 'https:') { throw new Error('Only HTTPS URLs are allowed'); } return url; } ``` ## Error Handling Don't expose sensitive information in errors: ```javascript // ❌ Bad - Exposes sensitive info app.post('/api/create-link', async (req, res) => { try { const link = await createLink(req.body); res.json(link); } catch (error) { res.status(500).json({ error: error.message }); // May expose API key or internal details } }); // ✅ Good - Generic error message app.post('/api/create-link', async (req, res) => { try { const link = await createLink(req.body); res.json(link); } catch (error) { console.error('Link creation error:', error); // Log internally res.status(500).json({ error: 'Failed to create link. Please try again.' }); } }); ``` ## Monitoring & Logging Log security-relevant events: ```javascript function logSecurityEvent(event, details) { const log = { timestamp: new Date().toISOString(), event: event, details: details, ip: details.ip, userAgent: details.userAgent }; // Send to logging service logger.security(log); // Alert on suspicious activity if (event === 'invalid_signature' || event === 'rate_limit_exceeded') { alertSecurityTeam(log); } } ``` ## Security Checklist - ✅ Store API keys in environment variables - ✅ Never commit secrets to version control - ✅ Use HTTPS for all API requests - ✅ Verify webhook signatures - ✅ Implement rate limiting - ✅ Validate all user input - ✅ Use idempotency keys for webhooks - ✅ Rotate API keys regularly - ✅ Log security events - ✅ Handle errors securely - ❌ Never expose API keys in client-side code - ❌ Never log sensitive data ## Next Steps - [Authentication Guide](/docs/guides/authentication-guide) - [Webhook Setup](/docs/guides/webhook-setup) - [API Reference](/docs/api) --- # Setup: Music Connect Source: https://docs.smartlinks.app/setup/music-connect For bands and musicians --- # App module: Music Player Source: https://docs.smartlinks.app/setup/apps/musicPlayer Learn how to set up the Music Streaming app, manage your track library, and customize artist profiles to share exclusive content with your fans. # Music Streaming App — User Guide Welcome to the **Music Streaming** app for SmartLinks. This guide walks you through setting up your music player and managing your track library. --- ## Overview The Music Streaming app lets you share your music with fans through an embeddable player. You can upload tracks, organise playlists, set up exclusive content for verified users, and customise your artist profile — all from the admin panel. --- ## Getting Started ### 1. Open the Admin Panel Navigate to the admin page for your collection. You'll see the Music Admin panel with three tabs: **Tracks**, **Playlists**, and **Artist Settings**. ### 2. Set Up Your Artist Profile Go to the **Artist Settings** tab and fill in: - **Artist / Band Name** (required) — The name displayed in the player header. - **Artist Bio** — A short biography. Keep it to 2–3 sentences. - **Artist Image URL** — A link to your profile photo. This appears in the "Now Playing" header. - **Banner Image URL** — A wider image used as a visual backdrop. ### 3. Add Social Links Still on the Artist Settings tab, you can add links to your profiles on: - Spotify - Apple Music - Instagram - Twitter / X - Your website These appear as icons in the player so fans can find you elsewhere. ### 4. Save Your Changes Click the **Save Configuration** button at the top of the admin panel. Changes are not saved automatically — always save before navigating away. --- ## Managing Tracks ### Adding a Track 1. Go to the **Tracks** tab. 2. Click **Add Track**. 3. Fill in the track details: - **Title** (required) - **Artist** (required) - **Album** (optional) - **Audio File** — Upload an MP3 file directly, or paste a URL to a hosted file. - **Cover Art URL** (optional) — A link to the track's artwork. - **Exclusive** — Toggle on if this track should only be fully available to verified users. - **Preview Duration** — For exclusive tracks, how many seconds non-verified users can hear (default: 30). 4. Click **Save** in the dialog. 5. Click **Save Configuration** to persist your changes. ### Uploading Audio Files You can drag and drop an MP3 file onto the upload area, or click to browse your files. The file is uploaded to SmartLinks asset storage and the URL is filled in automatically. Duration is detected from the file. ### Editing a Track Click the pencil icon on any track to reopen the editor and change its details. ### Deleting a Track Click the trash icon on a track to remove it. Remember to save afterwards. ### Reordering Tracks Drag tracks by the grip handle (the six-dot icon on the left) to change their order. The order you set here is the order fans see in the player. --- ## Managing Playlists 1. Go to the **Playlists** tab. 2. Click **Add Playlist**. 3. Give it a name, optional description, and optional cover art URL. 4. Select which tracks belong to the playlist. 5. Toggle **Exclusive** if the playlist should only be visible to verified users. 6. Save the playlist, then save the overall configuration. --- ## How Fans Experience the Player When a fan visits the public link, they see: - Your artist name and image in the header. - A list of available tracks they can play immediately. - Standard playback controls: play/pause, skip forward/back, progress scrubbing, and volume. - A full-screen player mode with audio visualisation. - Exclusive tracks show a lock icon and play only a short preview for unverified users. --- ## Bulk Import via CSV If you have many tracks to add at once, you can import them using a CSV file. The expected columns are: | Column | Required | Description | |--------|----------|-------------| | title | Yes | Track title | | artist | Yes | Artist name | | album | No | Album name | | duration | Yes | Duration in seconds | | audioUrl | Yes | Full URL to the MP3 file | | coverArt | No | URL to cover art image | | isExclusive | No | `true` or `false` (default: false) | | previewDuration | No | Seconds of preview for non-verified users (default: 30) | **Example CSV:** ```csv title,artist,album,duration,audioUrl,coverArt,isExclusive,previewDuration Moonshine,The Band,Debut Album,234,https://cdn.example.com/moonshine.mp3,https://cdn.example.com/cover.jpg,false,30 Secret Track,The Band,Debut Album,180,https://cdn.example.com/secret.mp3,,true,15 ``` CSV import is handled through the SmartLinks AI setup wizard — ask the assistant to "import tracks from CSV" and provide your file. --- ## Widgets The app includes an **Example Widget** that can be embedded in the SmartLinks portal. It shows a summary card with your app name and a button to open the full player. Widgets appear automatically where the platform places them — no extra setup is needed. --- ## FAQ & Troubleshooting **Q: I saved my config but the player still shows old data.** A: Try refreshing the public page. If the issue persists, open the admin panel and verify your changes were saved (check for the success toast message). **Q: Audio plays for the first track but not when I switch tracks.** A: This is usually a browser autoplay restriction. Ensure your audio files are served with proper CORS headers. Files uploaded through the admin panel are handled automatically. **Q: My uploaded file shows an error.** A: Check that the file is a valid MP3 and under the size limit. The upload progress bar will show an error message with details. **Q: Exclusive tracks play fully for everyone.** A: The exclusive/preview gating relies on the SmartLinks platform knowing whether the user is verified. Ensure the platform is passing user context correctly. **Q: I don't see the admin panel.** A: Make sure you're accessing the admin URL (`/admin.html#/`) and that you have admin permissions for the collection. **Q: How do I change the order of tracks?** A: On the Tracks tab, drag tracks using the grip handle on the left side of each track row. Save after reordering. --- # App module: Voting Source: https://docs.smartlinks.app/setup/apps/voting Learn how to set up and manage real-time polls, weighted voting, and proof-based contests for your products using the Smartlinks Voting App. # Voting App — User & Admin Guide This guide explains how to set up, manage, and run votes using the SmartLinks Voting App. It's written for brand managers, event organisers, and admins — no technical knowledge required. --- ## Overview The Voting App lets you create polls and votes attached to your digital twin products. Customers scan or visit a product page and cast their vote — you see results in real time. **What you can do:** - Create single-choice polls ("Pick one option") - Create weighted polls ("Allocate 10 votes across options") - Set open/close dates so voting runs on a schedule - Restrict voting to one vote per person, or allow repeat voting - Use proof-based voting where each physical item gets one vote - Display live results on a TV screen for events --- ## Getting Started ### Accessing the Admin Dashboard Open the admin interface by navigating to the admin URL provided by your SmartLinks portal. You'll see the **Vote Management Dashboard** showing all your votes organised by status: Open, Upcoming, and Closed. ### Creating Your First Vote 1. Click **Create New Vote** in the top-right corner. 2. Fill in the form: - **Title** — The name voters will see (e.g. "Best New Flavour"). - **Description** — A short explanation of what the vote is about. - **Rich Content** (optional) — Add formatted text, images, or graphics to the vote page using the rich text editor. - **Open Date** — When voting starts. Voters cannot cast votes before this time. - **Close Date** — When voting ends. No votes are accepted after this time. - **Choices** — Add at least two options. Each choice has a label and a colour (used in the results chart). 3. Click **Save** to publish the vote. Your vote is now live (or scheduled, if the open date is in the future). --- ## Voting Types ### Single-Choice Voting The default. Each voter picks one option. Simple and clear — great for "favourite" polls, audience picks, and quick decisions. ### Weighted Voting Each voter receives a budget of votes (e.g. 10) and distributes them across choices however they like. Useful when you want to measure strength of preference, not just first choice. To enable weighted voting: 1. Set **Voting Type** to "Weighted" when creating or editing a vote. 2. Set **Total Votes Per User** to the budget each voter receives (e.g. 10). ### Proof-Based Voting Each physical product (proof) gets one vote. Voters must enter their proof ID to cast a vote. This prevents ballot-stuffing and ties votes directly to product ownership. To enable proof-based voting: 1. Set **Voting Limit Type** to "Proof" when creating or editing a vote. 2. Voters will be prompted to enter their proof ID before voting. --- ## Managing Votes ### Editing a Vote From the dashboard, click the **pencil icon** next to any vote to open the edit form. You can change the title, description, dates, and choices at any time — even while the vote is open. > **Tip:** Changing choices on an open vote won't affect votes already cast, but new voters will see the updated options. ### Deleting a Vote Click the **bin icon** next to a vote. You'll be asked to confirm. Deleting a vote removes the configuration permanently — votes already cast remain in the system but are no longer displayed. ### Viewing Results Click the **eye icon** to open the public vote page, which shows the current results chart. Results update in real time (every 3 seconds). --- ## TV Display Mode The TV display mode shows live results on a large screen — perfect for events, conferences, and in-store displays. ### Opening TV Mode From the dashboard, click the **TV icon** next to any vote. A new browser tab opens with a full-screen results display optimised for large screens. ### What's Shown - Vote title and description - Real-time bar or grid chart of results - Total vote count - Auto-refreshing every few seconds ### Tips for Events - Use a browser in full-screen mode (press F11) for the cleanest look. - The display works on any screen — TV, projector, or monitor. - Results update automatically; no need to refresh the page. - You can toggle between bar chart and grid views. --- ## How Voters Use the App ### Casting a Vote 1. The voter opens the voting page (via a product scan, link, or QR code). 2. They see the vote title, description, and any rich content. 3. They select their choice and click to submit. 4. A confirmation message appears: "Thank you for voting!" ### Multiple Votes If **Allow Multiple Votes** is enabled, voters can return and vote again. Otherwise, they'll see a "Thank you" message and the results chart after their first vote. ### Weighted Voting (Voter Experience) 1. The voter sees all choices with allocation sliders or inputs. 2. They distribute their vote budget across choices. 3. They submit all allocations at once. 4. The system records each allocation as a separate vote event. --- ## Settings Reference | Setting | Description | Default | |---------|-------------|---------| | **Title** | Vote name shown to voters | Required | | **Description** | Brief explanation of the vote | Optional | | **Rich Content** | Formatted HTML content (images, styled text) | Optional | | **Open Date** | When voting starts (ISO date/time) | Required | | **Close Date** | When voting ends (ISO date/time) | Required | | **Voting Type** | `single` (pick one) or `weighted` (allocate budget) | Single | | **Total Votes Per User** | Vote budget for weighted voting | 1 | | **Allow Multiple Votes** | Whether the same person can vote more than once | No | | **Voting Limit Type** | `device` (browser-based) or `proof` (product-based) | Device | | **Choices** | List of options with labels and colours | Min. 2 | --- ## Bulk Import If you need to create many votes at once (e.g. for a product range), you can use the CSV import feature via the SmartLinks admin console. ### CSV Format ```csv productId,title,description,openDate,closeDate,votingType,totalVotesPerUser,allowMultipleVotes,choices prod_abc123,Best Feature Vote,Vote for your favourite,2025-03-01T00:00:00Z,2025-03-31T23:59:59Z,single,1,false,Dark Mode|Mobile App|API Access prod_def456,Colour Preference,Pick your favourite colour,2025-04-01T00:00:00Z,2025-04-15T23:59:59Z,weighted,10,false,Red|Blue|Green|Yellow ``` - **Choices** are separated by `|` (pipe character). - **Dates** must be in ISO 8601 format. - Each row creates one vote attached to the specified product. --- ## FAQ & Troubleshooting ### Why can't voters see my vote? - Check that the **Open Date** has passed. Votes with a future open date show as "Upcoming" and are not visible to public users. - Verify the vote is attached to the correct collection and product. ### Why does the results chart show zero votes? - Ensure voters are accessing the correct vote URL with the right `voteId` parameter. - Check that the vote status is "Open" — closed votes still show results but don't accept new votes. ### Can I re-open a closed vote? Yes — edit the vote and change the **Close Date** to a future date/time. ### Can I change choices after people have voted? You can add new choices or rename existing ones. Votes already cast are linked to the choice ID (not the label), so renaming a choice won't lose existing votes. Removing a choice will hide it from the results but won't delete the underlying vote data. ### How do I prevent people from voting twice? - **Default (device-based):** The system tracks whether a browser has already voted. This works for casual polls but can be bypassed by using a different browser or device. - **Proof-based:** Each proof ID can only vote once. This is the most reliable method for product-linked voting. ### What happens when a vote closes? - No new votes are accepted. - Results remain visible to anyone with the link. - The vote moves to the "Closed" section in the admin dashboard. - You can still view TV mode for closed votes. --- # App module: Link Page Source: https://docs.smartlinks.app/setup/apps/linkPage Learn how to configure and brand your SmartLinks Link Page, including managing link sections, embedding dynamic widgets, and tracking user scan analytics. # SmartLinks Link Page — User Guide > **Audience:** Brand managers, marketers, and anyone configuring the Link Page app. No technical knowledge required. --- ## Contents 1. [Overview](#overview) 2. [Getting Started](#getting-started) 3. [Admin Console Tour](#admin-console-tour) 4. [Links](#links) 5. [Sections](#sections) 6. [Widgets](#widgets) 7. [Branding](#branding) 8. [Variants (A/B Versions)](#variants-ab-versions) 9. [Translate](#translate) 10. [Analytics](#analytics) 11. [Templates & AI](#templates--ai) 12. [The Public Experience](#the-public-experience) 13. [FAQ](#faq) --- ## Overview The Link Page is a customisable landing page for your SmartLinks collection — a "Linktree for your products." When customers scan a SmartLinks tag, they land on this page styled with your branding, your links, and live widgets from your other SmartLinks apps. What you can do: - **Custom branding** — logo, hero image or media widget cover, colours, fonts, backgrounds - **Organised links** — group into sections with multiple visual layouts - **Embedded widgets** — pull live, interactive content from other SmartLinks apps - **Dynamic content** — titles, taglines, and images can auto-populate from your product data - **Conditional visibility** — show/hide links based on context (logged in, country, owned, etc.) - **Variants** — create alternate versions of the page for A/B testing or audience targeting - **Multi-language** — translate link titles and subtitles - **Analytics** — page views, clicks, top links, referrers, countries, devices --- ## Getting Started The Link Page admin opens automatically when you select the app for a collection in the SmartLinks Admin Console. **First-time setup:** if no configuration exists yet, you'll see the **Template Selector**. Pick an industry template, start from an AI-generated theme, or start from a blank wizard. Your collection's logo and name auto-populate via dynamic placeholders. Customise, then click **Save** in the top-right. The Save button shows a pulsing indicator when there are unsaved changes, and you'll be warned if you try to leave with unsaved work. --- ## Admin Console Tour The admin has five top-level tabs: | Tab | Purpose | |---|---| | **Links** | Add, edit, reorder, and organise links, sections, and widgets | | **Branding** | Text, header, images, background, colours, typography, footer | | **Variants** | Create and manage alternate versions of the page | | **Translate** | Manage translations for link titles and subtitles | | **Analytics** | Page views, clicks, top links, referrers, countries, devices | On desktop a **live preview** appears beside the editor — when you're editing a variant, the preview reflects that variant. On mobile/tablet, use the **Preview** button to toggle between editor and preview. Header controls also include **Apply Template** (reapply or switch templates anytime) and **Save**. --- ## Links ### Adding a link Click **+ Add** at the top of the Links tab or inside any section. The menu lets you create: - **External link** — a URL opened in the browser - **Internal app** — opens another SmartLinks app (with optional deep link) - **Widget** — embeds a live widget (see [Widgets](#widgets)) - **Image** / **Video** — quick media items When you choose **Internal app**, the **Installed App** picker appears first. Selecting an app auto-fills the title and icon, then you can refine title, subtitle, and pick a deep link. ### Link fields | Field | Notes | |---|---| | **Title** | Required. Supports dynamic values via the input mode toggle (literal / from product / from collection / advanced) | | **Subtitle** | Optional short description | | **URL or App / Deep Link** | Depending on type | | **Icon** | Built-in Font Awesome Pro icons or upload custom | | **Background image** | Optional; choose **Icon** mode (small) or **Background** mode (full-bleed) | | **Custom colour** | Override the section/page primary colour for this link | | **Visible** | Quick on/off without deleting | | **Conditions** | Set rules controlling when the link appears | ### Conditions tab Each link's edit dialog has a dedicated **Conditions** tab. Add one or more rules (e.g. "user is logged in", "country = DE", "proof is owned"). When previewing the admin you can use the **visibility simulator** to override conditions and check how the page looks under different scenarios. ### Managing links - **Drag** the handle (⠿) to reorder, including across sections - **Edit / Duplicate / Delete** via the row icons - **Toggle visibility** with the eye icon - Each row shows its **click count**; switch to **Stats mode** (bar-chart icon) to rank links by performance - Use the **filter** icon to focus on a single section --- ## Sections Sections group links and can each have their own display style and optional heading. Links not assigned to a section appear at the top of the page. ### Display styles | Style | Best for | |---|---| | **Full Width Links** | Classic vertical button list | | **Card Grid** | Resources, products | | **Bento Grid** | Mixed large/small editorial tiles | | **Masonry** | Varied content lengths | | **Icon Grid** / **Icon + Label Grid** | Social, quick actions | | **Compact List** | Long reference lists | | **Image Tiles** | Photo-led layouts | | **Flat Tiles** | Bold solid blocks | | **Square Tiles** | Uniform 1:1 grids | Add sections with **+ Add Section**, reorder via the ↑/↓ arrows, edit/collapse via the chevron and pencil. A section can only be deleted once empty. --- ## Widgets Widgets are live, interactive components borrowed from your other SmartLinks apps — competitions, social feeds, product cards, media players, and more. ### Adding a widget 1. **+ Add → Widget** in the Links tab. 2. Pick the **App** that provides the widget. 3. Pick the **Widget** within that app (some apps offer several). 4. Pick an **Instance** if relevant (e.g. a specific competition). 5. Configure widget-specific settings — these are defined by the widget itself and can include dynamic links. 6. Set **Title**, **Size**, and **Display Style**. ### Sizes | Size | Description | |---|---| | **Compact** | Minimal — tight spaces | | **Standard** | Balanced default | | **Large** | Full feature set | ### Display styles | Style | Description | |---|---| | **Framed** | Card with border, title, and icon | | **Minimal** | Rounded container, no header | | **Frameless** | No wrapper — widget fills its space | Widget link clicks are automatically tracked in analytics. --- ## Branding The Branding tab uses a vertical sub-nav with seven sections: ### Text - **Title** and **Tagline** — both support dynamic input modes so you can pull in `{{ product.name }}`, `{{ collection.description }}`, etc. without typing Liquid by hand. ### Header The page header is the area above your title/tagline. Choose what fills it: | Mode | Description | |---|---| | **None** | No header | | **Image** | A static hero / banner image | | **Media widget** | Embed a media-app widget (e.g. product gallery) as the cover; pulls images directly from your product | | **Product cover** | A peek-style preview that drags down to reveal the full media gallery | For the media widget option, pick the **Media app** — the widget itself surfaces its own settings (link target, layout, etc.) just like any other widget. ### Images Controls **logo** and **hero image** when used in image-based header modes: - Display order: Hero → Logo or Logo → Hero - Logo: image, shape (circle / rounded / square / full width), size, alignment - Hero: image, fit (cover / contain / fill / scale-down), height, aspect ratio, focal position > Tip: Use a transparent PNG for logos to avoid white boxes on coloured backgrounds. ### Background | Type | Options | |---|---| | **Solid** | Single colour | | **Gradient** | Two or three stops, eight directions | | **Image** | Image with blur (0–20px), overlay colour, overlay opacity | ### Colors - **Primary** — buttons, accents, active states - **Text** — main text colour - **Border radius** — None / Small / Medium / Large / Extra Large ### Typography - **Font Family** — choose a Google Font or any custom name - **Font URL** — auto-filled for Google Fonts, or paste your own ### Footer | Setting | Description | |---|---| | **Powered by SmartLinks** | Toggle attribution badge | | **Custom text** | Free text supporting dynamic values (e.g. `© 2026 {{ collection.name }}`) | | **Footer image** | Small logo or badge | | **External link** | Link to your main site with custom label | | **Footer links** | Add legal links from presets (Privacy, Terms, Cookies) or custom | ### AI theme generator At the top of the Branding tab, **Generate Theme with AI** suggests a coordinated palette, font, and radius based on your brand. Apply with one click; tweak from there. --- ## Variants (A/B Versions) Variants are alternate versions of the page. Use them for A/B tests, seasonal campaigns, or audience-specific experiences. ### Creating a variant Open the **Variants** tab and click **+ New Variant**. Choose how to start: - **Clone homepage** — copy the current live config - **Clone another variant** — start from an existing variant - **Start from template** — pick an industry template - **Start with AI wizard** — generate a fresh look - **Start blank** — empty canvas While editing a variant, the live preview reflects that variant (not the homepage). Each variant has its own conditions controlling when it's served. ### Promote & demote Variants double as a safe **draft workflow** for the live homepage: - **Promote a variant → homepage.** On any variant card, click **Promote**. The current live homepage is automatically snapshotted as a new draft variant ("Previous default — ") so you can roll back in one click, the variant's content becomes the new live homepage, and the promoted variant record is then deleted. - **Demote homepage → draft variant.** Click **Draft from default** in the Variants header bar. The current live homepage is copied into a new draft variant ("Default draft — ") and opened in the editor. Tweak and test it freely — the live homepage is untouched until you promote. Typical workflow: **Draft from default → edit & preview → Promote** when happy. The previous live version is kept as a draft so you can always revert. --- ## Translate The Translate tab appears when your collection has multiple languages. 1. Pick a **target language**. 2. For each title/subtitle, type a translation or click the **wand** to auto-translate with AI. 3. **Translate All** fills every empty entry in one click. Empty translations fall back to the original text. --- ## Analytics | Section | Shows | |---|---| | **Summary cards** | Page views, clicks, unique visitors, click-through rate, with period-over-period trend | | **Time range** | 7 / 30 / 90 days | | **Clicks over time** | Daily trend chart | | **Top links** | Most-clicked links | | **Top referrers** | Where traffic comes from | | **Countries** | Geographic split | | **Devices** | Mobile / desktop / tablet | Per-link stats are also visible inline on the Links tab; toggle **Stats mode** to rank by performance. --- ## Templates & AI Templates give you a styled starting point with sample sections and links. Available themes include Food & Beverage, Fashion & Apparel, Electronics & Tech, General Business, Health & Wellness, and Luxury & Premium. The **AI wizard** can also generate a colour palette and tagline tailored to your brand. You can launch it from first-run setup, the Branding tab, or when starting a new variant. > Applying a template replaces your current configuration. Save first if you want to keep what you have. --- ## The Public Experience When customers land on your page they see: 1. Your **header** (image, media widget, or none) and **branding** (logo, title, tagline) 2. Your **links**, organised into sections in the chosen layout 3. Any **embedded widgets** running live 4. Your **footer** with legal links and optional attribution - External links open in a new tab - Internal app links open the target SmartLinks app within the portal (with deep-link parameters when set) - Widgets are interactive in place — competitions, media, product cards all run on the page - Dynamic content (`{{ product.name }}`, etc.) adapts automatically to whichever product was scanned - If translations exist, customers see content in their preferred language automatically - Conditional links only appear when their rules are met --- ## FAQ **Can I undo changes?** Changes aren't saved until you click **Save**. Refresh the page to revert to the last saved version. Once saved there's no undo, but you can edit and save again. **My changes aren't appearing on the public page.** Make sure you clicked **Save**. If saved and still not visible, allow a moment for the CDN, or hard-refresh. **Can I have different pages per product?** Configuration is stored per collection, but dynamic placeholders (`{{ product.name }}`, `{{ product.image }}`, etc.) personalise each scan. Use **variants** for fully different layouts targeted by condition. **Why can't I delete a section?** Sections must be empty before deletion. Move or delete its links first. **Why doesn't my widget render?** The widget needs an **App** *and* a specific **Widget** within that app selected. If only one widget exists in the app it's chosen automatically; otherwise pick from the dropdown. **My link only shows sometimes.** Check its **Conditions** tab. Use the visibility simulator in preview to test scenarios. **How do I switch back to the homepage from a variant?** Close the variant editor (back arrow at the top of the Variants tab) — the preview returns to the homepage configuration. **Where do I find the icons?** Anywhere an icon picker appears, you can search the full Font Awesome Pro library or upload your own image. --- _Need more help? Reach out to your SmartLinks contact or the platform support team._ --- # App module: Gig Listings Source: https://docs.smartlinks.app/setup/apps/gigListing Learn how to manage your concert schedule with the Gig Listing app, including adding shows, setting up ticket links, and enabling location-based sorting for fans. # Gig Listing — User Guide Welcome to the **Gig Listing** app! This guide walks you through setting up and managing your concert schedule so fans can discover your upcoming shows. --- ## Overview Gig Listing is a SmartLinks microapp that lets you publish and manage your live concert schedule. Fans see your upcoming and past shows, can filter by location ("Near Me"), and link through to buy tickets. You manage everything from the admin console. --- ## Getting Started ### First-Time Setup 1. Open the **admin page** for your collection. The URL will include your `collectionId` and `appId` — these are set automatically by the platform. 2. You'll see an empty concert list with an **"Add New Concert"** button. 3. Click it to add your first gig! > **Tip:** If you have many gigs to add at once, ask your AI assistant to bulk-import them from a CSV file (see [Import / Bulk Setup](#import--bulk-setup) below). --- ## Adding a Concert Click **"Add New Concert"** in the admin console. Fill in the following fields: | Field | Required | Description | |-------|----------|-------------| | **Title** | ✅ | Name of the gig or event (e.g. "Summer Tour Kickoff") | | **Date** | ✅ | Concert date — pick from the calendar | | **Time** | ✅ | Start time in HH:MM format (e.g. "20:00") | | **Venue** | ✅ | Venue name (e.g. "The Fillmore") | | **Location** | ✅ | City and region (e.g. "San Francisco, CA") | | **Ticket URL** | ❌ | Link where fans can buy tickets | | **Sold Out** | ❌ | Toggle on if the show is sold out | | **Description** | ❌ | Any extra details about the show | ### Geocoding (Location on Map) When you type a location, the form will attempt to look up the latitude and longitude automatically. This powers the **"Near Me"** feature on the public page, which lets fans sort gigs by distance. - If geocoding doesn't find your location, you can leave it — the gig will still appear, just without distance sorting. - For best results, use a format like **"City, State"** or **"City, Country"**. --- ## Editing a Concert 1. Find the concert in your admin list. 2. Click the **pencil icon** (Edit). 3. Update any fields and click **Save**. The concert's `updatedAt` timestamp is refreshed automatically. --- ## Deleting a Concert 1. Find the concert in your admin list. 2. Click the **trash icon** (Delete). 3. The concert is removed immediately. > **Note:** Deletion is permanent — there is no undo. Double-check before deleting. --- ## How Fans See Your Gigs (Public Page) The public page shows two sections: - **Upcoming Shows** — Future gigs sorted by date, nearest first. - **Past Shows** — Previous gigs shown below (collapsed by default). ### "Near Me" Feature If a fan allows location access in their browser, gigs are sorted by distance with a distance badge (e.g. "12 km away"). Fans who decline location access see gigs sorted by date only. ### Ticket Links If you've added a ticket URL, fans see a **"Get Tickets"** button. Sold-out shows display a **"Sold Out"** badge instead. --- ## Import / Bulk Setup If you have many concerts to add, you can ask your AI assistant to import them from a CSV file. Here's the template: ```csv title,date,time,venue,location,latitude,longitude,ticketUrl,soldOut,description Summer Tour Kickoff,2026-06-15,20:00,The Fillmore,San Francisco CA,37.784,-122.433,https://tickets.example.com/1,false,Opening night Rock the Park,2026-07-04,19:00,Central Park,New York NY,40.785,-73.968,,false,Free outdoor festival ``` ### Field Rules - **date** must be `YYYY-MM-DD` format - **time** must be `HH:MM` format (24-hour) - **soldOut** should be `true` or `false` - **latitude/longitude** are optional but enable the "Near Me" feature - **ticketUrl** is optional — leave blank if no ticket link Your AI assistant will validate each row and report how many were imported successfully. --- ## Widgets The **UpcomingGigsWidget** can be embedded on other pages in the platform to show a preview of your next few gigs. | Size | What It Shows | |------|---------------| | **Compact** | Next 2 gigs — date and venue only | | **Standard** | Next 3 gigs — date, venue, location, and ticket link | | **Large** | Next 3 gigs — full detail with descriptions | The widget links back to your full gig listing page via the **"View All Gigs"** button. --- ## FAQ / Troubleshooting **Q: I added a concert but it doesn't show on the public page.** A: Check that the concert date is in the future. Past concerts appear in the "Past Shows" section, which may be collapsed. **Q: The "Near Me" feature isn't working for fans.** A: Fans need to grant location permission in their browser. If they decline, gigs are sorted by date instead. Also ensure your concerts have latitude/longitude set. **Q: I don't see the admin controls.** A: Make sure you're accessing the admin page (not the public page) and that your account has admin permissions for this collection. **Q: Can I reorder concerts manually?** A: Concerts are always sorted by date automatically — nearest upcoming first. You can't manually reorder them. **Q: How do I mark a show as sold out after it was listed?** A: Edit the concert and toggle the **Sold Out** switch on. The public page will show a "Sold Out" badge instead of the ticket link. **Q: What happens to past concerts?** A: They remain in your data and show in the "Past Shows" section. You can delete them from the admin if you want to clean up. --- # App module: Code Studio Source: https://docs.smartlinks.app/setup/apps/codeStudio Learn how to generate unique product identifiers, design custom QR labels, and export data or print physical tags using the Code Studio dashboard. # Code Studio — User Guide Welcome to **Code Studio**! This guide will walk you through everything you need to know to create unique codes for your products, design beautiful labels, and get them printed — all from one place. --- ## What is Code Studio? Code Studio helps you generate unique identifiers for your products and turn them into scannable QR codes, printed labels, or data files. Whether you're labelling wine bottles, tagging equipment, or preparing a product launch, Code Studio makes it simple. Every code you create links back to a **digital experience** — a page where your customers can learn about your product, register a warranty, enter a competition, or verify authenticity. Think of it as giving every physical item its own digital identity. --- ## Getting Started Code Studio is designed to be friendly from the very first click. What you see depends on where you've come from: ### If you opened a specific product You'll land on the **Quick QR** screen — a simple, single-page view that shows you: - Your product's QR code, generated instantly - Big buttons to **Download PNG**, **Download SVG**, or **Print a Sheet** of labels - A small **QR Settings** strip showing the code type (Portal Link, GS1, etc.) — click "Change settings" to tweak it, then **Save for whole collection** so every other product uses the same setup - A **"Want more?"** row with shortcuts to print a batch, export a CSV, design a custom label, or read this guide That's it. For most small businesses — a jam maker, a candle studio, a small vineyard — this single screen is everything you need. ### If you opened the collection (no specific product) You'll see a warm welcome screen with four big cards: - 🏷️ **Print labels for a batch** - 📊 **Export codes as a CSV** - 🎨 **Design a label** - ⚙️ **QR style & settings** Pick whichever matches your task — each card opens the right tool with everything pre-configured. ### Want the full power tools? Click **"Open Full Studio"** at the top of any screen. This unlocks the advanced multi-tab interface with everything Code Studio can do: | Tab | What it does | |-----|-------------| | **Setup** | Configure what kind of codes you want to generate and where they point | | **Print Labels** | Choose a label template, preview it, and print physical labels | | **Data Export** | Generate codes as downloadable files (CSV, JSON, or plain URLs) | | **Label Designer** | Create your own custom label designs from scratch or from a template | | **QR Styles** | Customise the look of your QR codes with colours, logos, and shapes | You don't need to use every tab — just the ones relevant to your workflow. Let's walk through each one. > **💡 First time here?** A short welcome wizard will pop up the very first time you visit, giving you a quick tour. You can dismiss it any time and it won't come back. --- ## Step 1: Set Up Your Codes Before generating anything, visit the **Setup** tab to tell Code Studio what kind of codes you'd like to create. ### Choosing a Code Type You have several options: - **Portal Link** — The most common choice. Each code links to your product's digital experience page, where customers can interact with your brand. - **Serial Number** — Just the unique identifier itself, without a URL. Useful if you're integrating with another system that builds its own links. - **GS1 Digital Link** — An industry-standard format used in retail and supply chain. If you're working with barcodes in supermarkets or need GS1 compliance, this is the one. Code Studio supports attaching a **batch/lot**, **expiry date**, and **best-before date** to each GS1 code, and respects whether each product *owns* its GTIN exclusively or shares it across the collection (see GS1 section below). - **Custom URL** — Point codes to any web address you like, with the serial number embedded in the URL. > **💡 Tip:** If you're not sure which to pick, **Portal Link** is the best starting point. It works out of the box and gives your customers a great experience. ### Setting a Base URL The base URL is the web address your codes will point to. This is usually pre-filled for you based on your account settings. You typically don't need to change it unless you're using a custom domain. ### Error Correction This controls how resilient your QR codes are to damage or printing imperfections: - **Low (L)** — Smallest QR code, but less tolerant of smudges or scratches - **Medium (M)** — A good balance for most uses *(recommended)* - **Quartile (Q)** — More resilient, slightly larger code - **High (H)** — Maximum resilience, largest code — great for industrial or outdoor use --- ## Step 2: Print Labels The **Print Labels** tab is where you go from digital codes to physical labels you can stick on your products. ### How It Works 1. **Pick a template** — Choose from our built-in designs or one of your own custom templates 2. **Preview** — See exactly what your label will look like with a sample code 3. **Configure** — Set how many labels you need and choose your paper/label format 4. **Generate & Print** — Create the PDF and send it to your printer ### Built-in Templates We've included several ready-to-use templates for common scenarios: - **Product Label** — A clean, professional label with QR code and product name - **Shipping Label** — Includes space for addresses and tracking information - **Freezer Label** — Designed for cold-storage environments with bold, readable text - **Wine Label** — Elegant design suited for bottles and premium products - **Asset Tag** — Compact label for equipment and inventory tracking - **Event Ticket** — Fun design with event details and a scannable code - **Warranty Card** — Includes warranty information alongside the QR code ### Customising a Template See a template you *almost* like? Click **"Customise Template"** to open it in the Label Designer, where you can tweak colours, add your logo, change text, and more. Your customised version is saved separately, so you won't overwrite the original. ### Print Formats Code Studio supports several ways to get your labels onto paper: - **Sheet Labels (Avery)** — Standard label sheets you can buy at any office supply store. We support popular formats like Avery 5160, 5260, and more. Just load the sheet into your printer and go. - **Continuous Roll** — For thermal label printers that use a continuous roll of label stock. Great for high-volume printing. - **Zebra (ZPL)** — Direct commands for Zebra industrial printers. If you have a Zebra printer, this gives you the fastest, highest-quality output. > **💡 Tip:** For small batches (under 100 labels), sheet labels are the easiest option. For larger runs, consider a thermal or Zebra printer for speed and cost savings. --- ## Step 3: Export Your Data Sometimes you don't need printed labels — you need the raw data. The **Data Export** tab lets you generate codes and download them as files. ### When to Use Data Export - **Sending codes to a print shop** — They'll handle the label design and printing - **Importing into another system** — Your inventory, ERP, or logistics software - **Sharing with a partner** — Give them a file of codes to use in their own process - **Backup and record-keeping** — Keep a copy of all generated codes ### Export Formats - **CSV** — A spreadsheet-compatible file. Opens in Excel, Google Sheets, or any data tool. Each row is one code with columns you choose. - **JSON** — Structured data for developers or technical integrations. Perfect for feeding into APIs or automation tools. - **URL List** — A simple text file with one URL per line. Quick and easy for basic needs. ### Choosing Columns (CSV & JSON) You can pick exactly which fields to include: - **Serial Code** — The unique identifier - **URL** — The full web address the code points to - **Serial Index** — The sequence number (1, 2, 3...) - **Product ID** — Which product the code belongs to - **Variant ID** — The specific product variant (size, colour, etc.) - **Batch ID** — The production batch - **GTIN** — The Global Trade Item Number (for GS1 users) - **Date** — When the code was generated > **💡 Tip:** For most use cases, just **Serial Code** and **URL** are enough. Add more columns if your downstream system needs them. --- ## Designing Your Own Labels The **Label Designer** is a visual editor where you can create completely custom label layouts. Think of it as a simple design tool built specifically for labels. ### Starting a Design You can start in two ways: 1. **From scratch** — Open the Label Designer and add elements one by one 2. **From a template** — Pick a built-in template in the Print Labels tab, then click "Customise Template" to use it as a starting point ### What You Can Add - **Text** — Product names, descriptions, dates, or any custom text. Supports **Liquid templates** so text can automatically fill in with product data (more on this below). - **QR Code** — A scannable code that links to your product's digital experience - **Barcode** — Traditional linear barcodes for retail or warehouse use - **Images** — Your logo, product photos, certification marks, or decorative elements - **Shapes** — Rectangles, circles, and lines for layout and visual design ### Background Images Want a full-bleed background? Add an image element and use the **"Set as Background"** option. This places it behind all other elements and stretches it to fill the label. For circular or die-cut labels, you can enable **bleed** to extend the background slightly beyond the label edge, ensuring clean printing with no white borders. ### Layer Order When elements overlap, you control which one appears on top: - **Bring to Front** — Moves an element above everything else - **Send to Back** — Moves an element behind everything else This is especially useful when placing text or QR codes over a background image. ### Dynamic Text with Liquid Liquid is a simple template language that lets your labels automatically include product-specific data. Instead of typing "Batch 42", you can use a template like `Batch {{batch}}`, and Code Studio will fill in the real batch number for each label. Common Liquid variables: | Variable | What it shows | |----------|--------------| | `{{serial}}` | The unique serial code | | `{{productName}}` | The product's name | | `{{collectionName}}` | The brand or collection name | | `{{batch}}` | The batch identifier | | `{{variant}}` | The variant (size, colour, etc.) | | `{{date}}` | Today's date | | `{{index}}` | The label number in the sequence | | `{{url}}` | The full URL the QR code points to | > **💡 Tip:** You can see a live preview of your Liquid text as you type. If a variable doesn't have data yet, it'll show as empty — don't worry, it'll fill in when you generate real codes. ### Saving Templates Click **Save** to store your design as a reusable template. Give it a clear name so you can find it later. Your saved templates appear in both the Label Designer and the Print Labels template picker. --- ## Customising QR Code Appearance The **QR Styles** tab lets you make your QR codes look great — not just functional. ### What You Can Customise - **Colours** — Change the dark and light colours of the QR pattern. Use your brand colours for a cohesive look. - **Corner Style** — Choose between sharp squares, rounded corners, or dots for the QR pattern modules. - **Logo** — Add your brand logo to the centre of the QR code. The code automatically adjusts its error correction to remain scannable. - **Frame** — Add a border or call-to-action frame around the QR code (e.g., "Scan Me"). > **⚠️ Important:** Always test your styled QR codes with a real phone scanner before printing. Heavy customisation (dark colours on dark backgrounds, very large logos) can make codes harder to scan. --- ## Working with GS1 Digital Links If you've chosen **GS1 Digital Link** in Setup, a few extra options become available. ### GTIN ownership: own vs shared Every GS1 code carries a **GTIN** — the global product number. Code Studio needs to know whether each product owns its GTIN outright, or shares it with others in your collection: - **Owns the GTIN** (`ownGtin = true`) — the URL is the clean root form, e.g. `https://your-resolver/01/05012345678900/21/SN-0001`. Use this when the GTIN uniquely identifies one product. - **Shared GTIN** (`ownGtin = false` or blank) — Code Studio prefixes the URL with your collection's short ID, e.g. `https://your-resolver/gc/acme/01/05012345678900/21/SN-0001`, so the resolver can tell which product was scanned. This is the safe default for collections where multiple products might share a GTIN. You set this per product. Bulk importers can supply an `ownGtin` column in their product CSV. ### Picking a batch When you generate a GS1 code in **Quick QR** or **Bulk Generate**, you can click **"Pick batch"** to: - **Choose an existing batch** from the collection (Code Studio looks them up for you), or - **Enter a custom batch/lot** by hand The batch you pick gets embedded in the URL as GS1 AI 10. If the batch carries an **expiry date** (AI 17) or **best-before date** (AI 15), those are appended too. ### Resolver choice By default, GS1 codes resolve through your portal's domain. If you need codes that work through the official GS1 resolver, toggle **"Use official id.gs1.org resolver"** in Setup. ### Trademark and GTIN display GS1 has specific guidance about showing the GS1® trademark and a human-readable GTIN alongside the QR code. Toggle these in Setup: - **Show GS1® trademark** — adds the trademark above the QR code - **Show GTIN text** — adds the readable GTIN beneath the QR code --- ## Common Use Cases ### 🍷 Wine & Spirits **Goal:** Add a scannable code to each bottle that links to tasting notes, food pairings, and the winemaker's story. 1. Go to **Setup** → select **Portal Link** 2. Open **Print Labels** → choose the **Wine Label** template 3. Customise with your vineyard's logo and colours 4. Set quantity to match your production run 5. Generate and print on waterproof label stock ### 📦 Inventory & Asset Tracking **Goal:** Tag every piece of equipment with a unique code for easy lookup and maintenance tracking. 1. Go to **Setup** → select **Portal Link** or **Serial Number** 2. Open **Print Labels** → choose the **Asset Tag** template 3. For high-volume tagging, use **Zebra (ZPL)** format for industrial printers 4. Alternatively, use **Data Export** → CSV to import codes into your asset management system ### 🎫 Events & Tickets **Goal:** Create unique, scannable tickets for an event. 1. Go to **Setup** → select **Portal Link** 2. Open **Print Labels** → choose the **Event Ticket** template 3. Customise with event name, date, and branding 4. Print on card stock or use a thermal printer for wristbands ### 🏭 Manufacturing & Supply Chain **Goal:** Serialise products with GS1-compliant codes for retail distribution. 1. Go to **Setup** → select **GS1 Digital Link** 2. Enter your GTIN and configure the GS1 resolver 3. Use **Data Export** → CSV to generate a file for your production line 4. Or use **Print Labels** → **Zebra (ZPL)** for direct printing on the factory floor ### 🎁 Small Business & Handmade Goods **Goal:** Add a personal touch to handmade products with scannable codes linking to your brand story. 1. Go to **Setup** → select **Portal Link** or **Custom URL** 2. Open **Label Designer** → create a design that matches your brand aesthetic 3. Print on sheet labels (Avery format) using a regular home or office printer 4. Stick them on your products, packaging, or thank-you cards --- ## Tips & Best Practices ### Printing Quality - **Use a laser printer** for the sharpest QR codes on sheet labels - **Test scan first** — always print one label and scan it with your phone before doing a full run - **Mind the size** — QR codes should be at least 2cm × 2cm (about 0.8 inches) for reliable scanning - **High contrast** — Dark codes on light backgrounds scan best. Avoid light grey on white. ### Organisation - **Name your templates clearly** — "Spring 2026 Rosé - Back Label" is better than "Template 3" - **Use batches** — Generate codes in logical batches that match your production runs - **Keep records** — Export a CSV backup whenever you generate a new batch of codes ### Troubleshooting | Problem | What to try | |---------|------------| | QR code won't scan | Increase the error correction level in Setup, or make the code larger | | Labels are misaligned | Check you've selected the correct Avery template for your label sheets | | Text is cut off on labels | Open the Label Designer and resize or reposition the text element | | Codes aren't generating | Make sure you have a product selected in your current context | | Preview looks different from print | Use the Print Preview to check layout before sending to your printer | --- ## Glossary | Term | What it means | |------|--------------| | **Serial Number** | A unique code assigned to each individual item | | **QR Code** | A square barcode that smartphones can scan to open a web page | | **Portal Link** | A web address that takes customers to your product's digital experience | | **GS1 Digital Link** | An industry-standard URL format used in global retail and supply chains | | **GTIN** | Global Trade Item Number — the barcode number on retail products | | **Liquid Template** | A way to insert dynamic data (like product names) into your label text | | **Avery** | A popular brand of printable label sheets, used as a standard format | | **ZPL** | Zebra Programming Language — commands for Zebra industrial label printers | | **Bleed** | Extending a design slightly beyond the label edge for clean, borderless printing | | **Error Correction** | How well a QR code can still be scanned if part of it is damaged or obscured | --- ## Need Help? If you get stuck or have questions, reach out to your SmartLinks administrator. They can help with: - Setting up your product catalogue - Configuring GS1 settings - Connecting printers - Managing user access --- *Code Studio is part of the SmartLinks platform. For technical documentation, visit your organisation's SmartLinks admin portal.* ## Future surfaces This app does not currently ship a MobileAdminContainer. Code Studio is a desk-based admin tool — designing labels, configuring QR styles, and bulk-generating codes — with no field/operator scan-and-verify workflow. If such a workflow is added later (e.g. on-site label verification by a brand rep), follow migration step 12. --- # App module: Tap To Donate Source: https://docs.smartlinks.app/setup/apps/tap-to-donate This guide provides an end-to-end manual for setting up and managing the Tap to Donate app, covering donor settings, Stripe payments, and Gift Aid configurations. # Tap to Donate — User & Admin Guide A practical, end-to-end manual for brand managers, marketing teams, and charity admins running the Tap to Donate experience. --- ## 1. What the app does Tap to Donate turns a physical NFC badge, QR code, or shareable link into a one-tap charitable donation page. A supporter taps the badge with their phone, picks (or types) an amount, pays with Apple Pay / Google Pay / card, and — if eligible — adds a Gift Aid declaration so your charity can claim the extra 25%. **Who it's for:** - Charities running awareness or fundraising campaigns. - Event organisers who want collection tins replaced with a tap-to-pay surface. - Brands attaching "give back" donation moments to products or experiences. **What you can configure:** charity name, welcome and thank-you messaging, logo, accent colour, preset amounts, currency, Gift Aid mode, and your Stripe payment account. --- ## 2. Getting started — first-time setup 1. Open your collection in the SmartLinks platform and add the **Tap to Donate** app. 2. Open the app's **admin console** (the gear/settings icon next to the app). 3. You'll land on the **Donation Settings** screen. Work through the tabs from left to right: - **Content** — name, logo, colour, messaging. - **Amounts** — preset values donors can pick. - **Stripe** *(collection only)* — connect your payment account. - **Gift Aid** — turn on UK Gift Aid declarations. - **Tracking** — view donations once they start coming in. 4. After every change, click **Save Changes** in the top-right. Nothing is live until you save. > **Tip:** A blue notice at the top of the page tells you when you're editing settings for a single product (rather than the whole collection). An orange notice means this product has its own override — you can delete it to fall back to the collection defaults. --- ## 3. Creating records — admin tabs explained ### 3a. Content tab | Field | What it does | |---|---| | **Charity Name** | Used on receipts, confirmations, and as the homepage title by default. Required. | | **Homepage Title** *(optional)* | Override the title shown at the top of the donation page. Leave blank to fall back to the charity name. | | **Logo URL** *(optional)* | A link to your logo image (PNG/JPG/SVG). Shown at the top of the donor experience. | | **Logo Size** | Slider, 32–200px. Controls how tall the logo appears. Default 96px. | | **Accent Colour** | Colour picker. Sets the colour of the main donate button, selected amount tile, and the welcome pill. | | **Welcome Message** | Rich-text editor shown above the amount picker. Keep it short and warm. | | **Thank-You Message** | Rich-text editor shown after a successful donation. | | **Redirect After Donation** *(if other apps installed)* | Pick another app to send donors to after they finish. Leave as "Back to product page" to send them back to the standard collection view. | ### 3b. Amounts tab | Field | What it does | |---|---| | **Preset Amounts** | Drag-to-reorder list of suggested donations. Each row has an amount and an optional label (e.g. "Buy a coffee"). Use the **+** button to add new presets and the trash icon to remove. | | **Allow Custom Amount** | Toggle. When on, donors get an "Other amount" option. | | **Minimum Amount** | Smallest custom donation accepted. Defaults to 1. | | **Maximum Amount** | Largest custom donation accepted. Defaults to 10,000. | | **Currency** *(set in Content tab)* | GBP, USD, or EUR. Determines the currency symbol shown everywhere. | ### 3c. Stripe tab *(collection-level only)* | Section | Field | What it does | |---|---|---| | Payment Mode | **Test Mode** | When on, payments are simulated — no real money moves. Turn off when you're ready to go live. | | Stripe API Keys | **Publishable Key** | Starts with `pk_test_…` or `pk_live_…`. Copy from your Stripe Dashboard → Developers → API keys. | | | **Secret Key** | Starts with `sk_test_…` or `sk_live_…`. Stored privately and used server-side to take payments. | | Payment Element Appearance | **Theme** | Visual style of the Stripe form: *Stripe* (default), *Night* (dark), or *Flat* (minimal). | | | **Layout** | How payment methods are displayed: *Auto*, *Tabs*, or *Accordion*. | | | **Business Name** | Optional text shown on the payment form / bank statement reference. | | Digital Wallets | **Apple Pay** | Show on iOS / Safari devices. Uses the "Learn more about Apple Pay readiness" link to check setup. | | | **Google Pay** | Show on Android / Chrome devices. | | | **Stripe Link** | One-click checkout from saved details. Disable if you only want card + wallets. | | Billing Details | **Name / Email / Phone / Address** | For each, choose whether Stripe collects it (*Auto*) or hides it (*Never*). | > **Apple Pay readiness:** Click the link under the Apple Pay toggle to open a checklist. If anything is missing (e.g. domain not registered with Stripe), use the **Copy instructions** button to get a ready-to-send setup guide. ### 3d. Gift Aid tab | Field | What it does | |---|---| | **Enable Gift Aid** | Toggle. When on, UK donors are asked to declare Gift Aid after paying. | | **Collection Mode → Basic Declaration** | Just a checkbox with the standard HMRC wording. Use when you already hold donor records. | | **Collection Mode → Full Details Collection** | Asks for first name, last name, full address, and postcode. Required if you don't already have donor records. | ### 3e. Tracking tab This tab is read-only after donations come in — see **Section 6 — Managing records**. --- ## 4. Day-to-day usage — what donors see 1. **They tap or scan.** The donation page opens with your logo, charity name, and welcome message. 2. **They pick an amount.** Either tap a preset tile or tap **Other amount** to type their own. 3. **They pay.** If Stripe is configured, the payment form appears with their available wallets (Apple Pay, Google Pay) and card option. In test mode, payment is simulated. 4. **Gift Aid prompt.** If enabled, they're asked to confirm — *Basic* shows just a checkbox; *Detailed* asks for name, address, and postcode. 5. **Thank-you screen.** They see your thank-you message with a **Done** button (which sends them back to the product page or your chosen follow-up app) and a **Donate again** option. If a donor arrives via a deep link with an amount preselected (e.g. from a widget tap), they skip straight to the payment step. --- ## 5. Widgets Tap to Donate ships one widget that other SmartLinks apps and pages can embed: ### DonationWidget A self-contained donation card. Shows your preset amounts, accepts a custom amount if enabled, and runs the full pay → Gift Aid → thank-you flow inline (so donors never leave the host page). | Size | Best for | What's shown | |---|---|---| | **compact** | Sidebars, dense layouts, small slots | Icon + amounts only, no welcome text | | **standard** | Most embed locations | Title, welcome message, amounts, custom amount, pay button | | **large** | Hero placements, dedicated donation slots | Same as standard with full visual prominence | After someone donates through the widget, the same browser session shows a thank-you state until reset. --- ## 6. Managing records — the Tracking tab Once donations start coming in, the Tracking tab gives you two views. ### Header summary - **Total raised**, donation count, and Gift Aid total appear in the top-right. ### Leaderboard - Ranks each NFC badge / proof by total raised. - Columns: rank, badge ID, donation count, total, average, Gift Aid count. ### Donation Log - Lists every individual donation with date, amount, Gift Aid yes/no, and donor PII (when collected). - Use the **search box** to filter by badge ID, donor name, postcode, payment ID, or amount. ### Actions | Button | What it does | |---|---| | **Gift Aid CSV** | Downloads a HMRC-ready CSV of all Gift Aid donations including donor name, address, and postcode. | | **Clear All** | Permanently deletes every donation record for this collection. Cannot be undone — use with care. | | **⋯ → Obfuscate** *(per row)* | Replaces donor name and address with `***` markers (for GDPR right-to-erasure requests) while keeping the donation amount. | | **⋯ → Delete** *(per row)* | Removes a single donation record entirely. | --- ## 7. Import / bulk setup You can pre-configure donation settings per product (e.g. different presets per item) by importing a CSV through the SmartLinks platform's app importer. ### CSV template ```csv productId,charityName,currency,presetAmounts,allowCustomAmount,giftAidEnabled prod_001,Save the Children,GBP,"[{""id"":""1"",""amount"":5},{""id"":""2"",""amount"":10}]",true,true prod_002,Oxfam,USD,"[{""id"":""1"",""amount"":10},{""id"":""2"",""amount"":25}]",true,false prod_003,Local Foodbank,GBP,"[{""id"":""1"",""amount"":3,""label"":""Buy a meal""},{""id"":""2"",""amount"":15,""label"":""Feed a family""}]",true,true ``` ### Field reference | Field | Required | Type | Notes | |---|---|---|---| | `productId` | Yes | text | The SmartLinks product ID this row applies to. | | `charityName` | Yes | text | Charity / campaign name shown to donors. | | `currency` | No | GBP / USD / EUR | Defaults to the collection currency. | | `presetAmounts` | No | JSON array | Quote-escaped JSON. Each entry needs `id` and `amount`; `label` is optional. | | `allowCustomAmount` | No | true / false | Defaults to true. | | `giftAidEnabled` | No | true / false | Defaults to the collection's Gift Aid setting. | > Stripe keys, Apple Pay / Google Pay toggles, and the welcome/thank-you messages are **not** importable per-product — they live at the collection level. --- ## 8. FAQ & troubleshooting **Why can't donors pay — they only see "Donations not yet configured"?** The collection has no charity name set, or no preset amounts. Open the admin console → **Content** → set a charity name → **Amounts** → add at least one preset → **Save Changes**. **Why doesn't Apple Pay show up on iPhone?** Three things must be true: (1) the donor is on Safari on iOS, (2) you're in **live mode** with live Stripe keys, and (3) your public donation domain is registered with Stripe. Open the **Stripe** tab and click **Learn more about Apple Pay readiness** — it runs the checks and gives you copy-paste instructions for your team. **I changed the preset amounts but the donor still sees the old ones.** Make sure you clicked **Save Changes** at the top of the admin page. If you're editing a specific product (blue or orange notice at the top), changes save to that product only — open the collection-level admin to change the defaults. **Can I switch from test to live after launch?** Yes. Open **Stripe → Payment Mode** and turn off Test Mode. You also need your **live** publishable and secret keys filled in, otherwise donors will see an error. **My CSV import skipped some rows.** Common causes: missing `productId`, malformed JSON in `presetAmounts` (every double-quote must be escaped as `""`), or unsupported `currency` values (only GBP, USD, EUR are allowed). **A donor asked to be forgotten — what do I do?** Open **Tracking → Donation Log**, find their donation(s), click **⋯ → Obfuscate** to wipe their name and address while keeping the donation total in your books, or **⋯ → Delete** to remove the record entirely. **Where do donors go after donating?** By default, back to the product page they came from. If you set a **Redirect After Donation** app on the Content tab, they'll be sent to that app instead. **Can I have different settings per product?** Yes. Open the admin from a specific product and make changes — a product-level override is created automatically. Use the **Delete Product Config** button to revert to the collection defaults. **Why does my Gift Aid CSV export look empty?** Only donations where the donor actually ticked the Gift Aid box and provided a name will appear. Donations where Gift Aid was skipped are excluded from the export. --- # App module: Competitions Source: https://docs.smartlinks.app/setup/apps/competitions Learn how to set up and manage digital giveaways, from creating simple polls to complex form-based entries, including entry rules, CRM integration, and analytics. # Competition App — User & Admin Guide Welcome to the Competition App! This guide covers everything you need to know to set up and run competitions for your brand. --- ## What Does This App Do? The Competition App lets you create and manage promotional competitions and giveaways that your customers can enter. You can run simple single-question competitions (great for quick giveaways and email capture) or detailed form-based competitions with multiple questions. Customers enter via the public portal — either by scanning a SmartLinks tag, clicking a link, or through an embedded widget on your site. --- ## Getting Started ### First-Time Setup 1. **Open the admin console** — Navigate to `admin.html` with your collection and app IDs in the URL. You'll see the Competition Management dashboard. 2. **General settings** — The app supports running multiple competitions at the same time by default. If you only want one active competition at a time, this can be configured in the app settings. 3. **Create your first competition** — Click the **"Create New Competition"** button in the top-right corner. ### Creating a Competition The competition editor has four tabs: #### Basic Info - **Title** — The name your customers will see (e.g., "Summer Giveaway 2025") - **Description** — A short summary shown on the competition card - **Competition Type** — Choose between: - **Simple Competition** — A single multiple-choice question. Perfect for quick giveaways, polls, or email capture campaigns. - **Form-based Competition** — A multi-field form for detailed entries with custom questions. Use this when you need more than a single question. - **Status** — Controls whether the competition accepts entries: - **Draft** — Not visible to customers. Use this while you're still setting things up. - **Active** — Live and accepting entries (within the date window). - **Closed** — No longer accepting entries but still visible. - **Archived** — Hidden from all views. - **Open Date / Close Date** — The date window during which entries are accepted. The server enforces these dates automatically — even if someone tries to submit outside the window, it will be rejected. #### Contact Fields Configure which personal details to collect from participants. These are used to create or update contacts in your CRM automatically: - First name and last name (on by default) - Email address (on by default — recommended for winner notification) - Phone number (optional) - Company name (optional) #### Entry Configuration **For simple competitions:** - **Question** — The multiple-choice question (e.g., "What is your favourite season?") - **Answer Options** — Add as many options as you like (minimum 2). Click "Add Option" to add more. - **Allow custom answer** — Toggle this on to show an "Other" option where participants can type their own answer. - **Store answer in outcome** — When enabled, the selected answer is recorded as the entry's outcome, making it easy to see answer distribution in analytics. **For form-based competitions:** - **Form ID** — Link to an existing SmartLinks form configuration. The form will be rendered using the SmartLinks Form Renderer. - **Quiz Mode** — Optionally enable quiz scoring by selecting which form field contains the answer and what the correct answer is. A quiz score will be calculated and stored with each entry. #### Content & Terms - **Rich Content** — Additional HTML content displayed on the competition page (e.g., prize descriptions, rules). - **Competition Details** — Extra details shown alongside the competition. - **Images** — Add image URLs to create a gallery on the competition page. - **Terms and Conditions** — Legal text displayed to participants before they submit. Participants must agree to these before entering. ### Saving Your Competition Click **"Save Competition"** at the bottom of the editor. The system will: 1. Validate all required fields 2. Create the competition definition 3. Set up server-side entry validation (date windows, duplicate prevention) You'll see a success notification and be returned to the competition list. --- ## Day-to-Day Usage ### How Customers Enter When a customer visits the public portal: 1. If there's only one active competition, they're taken directly to it. 2. If there are multiple active competitions, they see a selection screen and pick one. 3. They fill in their contact details and answer the question (or complete the form). 4. They submit their entry and see a success confirmation. 5. If they've already entered (and duplicate prevention is on), they'll see a message telling them so. ### The Competition Widget A compact widget can be embedded on other SmartLinks pages to promote active competitions. It shows: - The competition title and status - A countdown timer to the close date - An "Enter Competition" button The widget automatically shows the most recent active competition. --- ## Managing Competitions ### Viewing Entries From the competition list, click the **entries icon** on any competition to see all submissions. The entries view shows: - Participant name and email - Their answer or form data - Submission timestamp - Winner status ### Selecting a Winner 1. Go to the entries view for a competition 2. Click **"Select Random Winner"** — the system randomly picks an entry 3. The winner is highlighted and a winner event is recorded 4. You can **cancel** the winner selection and re-run if needed ### Changing Competition Status You can change a competition's status at any time: - Set to **Active** to start accepting entries - Set to **Closed** to stop accepting entries - Set to **Archived** to hide it from all views Remember: the open/close dates are also enforced server-side. Even if the status is "Active", entries won't be accepted outside the date window. ### Editing a Competition Click the **edit icon** on any competition in the list. You can change any field — title, dates, question, options, etc. Changes take effect immediately after saving. ### Deleting a Competition Click the **delete icon** and confirm. This permanently removes the competition definition and its entry validation rules. **This cannot be undone.** Existing entry data in the interactions log is preserved. --- ## Import / Bulk Setup You can create multiple competitions at once by importing a CSV file. Here's a template: ```csv title,description,competitionType,openDate,closeDate,status,question,options,termsAndConditions "Summer Giveaway","Win a prize pack!",simple,2025-06-01T00:00:00Z,2025-06-30T23:59:59Z,active,"What is your favourite season?","Spring,Summer,Autumn,Winter","Must be 18+" "Photo Contest","Submit your best photo",complex,2025-07-01T00:00:00Z,2025-07-31T23:59:59Z,draft,,,"Standard T&Cs apply" ``` **Field notes:** - `competitionType` must be `simple` or `complex` - `openDate` and `closeDate` must be ISO 8601 format - `options` should be comma-separated within quotes - Leave `question` and `options` empty for `complex` competitions --- ## Widgets The **CompetitionWidget** can be placed on product pages, proof pages, or any SmartLinks portal page. It comes in three sizes: | Size | What's Shown | |------|-------------| | **Compact** | Trophy icon, title, and status badge | | **Standard** | Title, description, countdown timer, and entry button | | **Large** | Full detail including close date and extended description | The widget automatically finds the most recent active competition and displays it. Clicking the entry button navigates the user to the full competition page. --- ## FAQ & Troubleshooting ### Why can't customers enter the competition? Check these things in order: 1. **Status** — Is the competition set to "Active"? 2. **Dates** — Is the current date between the open and close dates? 3. **Duplicate prevention** — Has the customer already entered? Each person can only enter once. ### Can I run multiple competitions at the same time? Yes! By default, multiple competitions can be active simultaneously. Customers will see a selection screen if more than one is available. ### What happens when I select a winner? The system randomly selects one entry and records a "winner" event. The winner's entry is flagged in the entries list. You can cancel and re-run the selection if needed. ### Can I change the dates after a competition has started? Yes, you can edit the dates at any time. The new dates take effect immediately for new entry attempts. Existing entries are not affected. ### What's the difference between "simple" and "complex" competitions? - **Simple**: One multiple-choice question. Quick to set up, great for giveaways. The answer is stored directly as the entry outcome, making analytics straightforward. - **Complex**: A full multi-field form. Use when you need detailed entries (e.g., photo submissions, essay contests, multi-question surveys). Form data is stored in the entry metadata. ### How do I see entry statistics? Entry counts and answer distributions are available through the SmartLinks analytics dashboard. Each competition tracks total entries, answer breakdowns (for simple competitions), and winner status. ### Can customers enter from a widget? The widget shows competition info and a call-to-action button. Clicking it navigates to the full competition page where they can enter. The widget itself doesn't contain the entry form. --- # App module: Attendance Source: https://docs.smartlinks.app/setup/apps/attendance Set up and manage the Attendance Tracker app to reward fan engagement through event check-ins, geolocation verification, and QR code integration. # Attendance Tracker — Admin & Setup Guide Welcome! This guide walks you through setting up and managing the Attendance Tracker app for your collection. It's written for **admins and business users** — no coding required. --- ## Overview The Attendance Tracker lets your fans check in at events or venues and rewards them with points, badges, streaks, and leaderboard rankings. It works with your existing fixtures, concerts, or custom events — or as a standalone venue loyalty tracker. --- ## Getting Started ### 1. Open the Admin Panel The Attendance Tracker admin is available inside the SmartLinks Admin Console. Navigate to your collection, find the Attendance Tracker app, and open its settings. ### 2. Choose Your Attendance Mode You'll first pick how attendance is tracked: | Mode | Best For | Example | |------|----------|---------| | **Per Event** | Tracking attendance at individual matches, concerts, shows | "Were you at the semi-final?" | | **Per Location** | Tracking repeat visits to a venue or store | "Visit our shop 10 times for a reward" | - **Per Event** pulls events from data sources (fixtures, concerts, or custom events you add). - **Per Location** lets you select venues — fans can check in each time they visit. ### 3. Configure Event Sources (Per-Event Mode) If you chose **Per Event**, you need at least one event source: - **Fixtures** — Pulls from your Fixtures Manager app (matches, games). - **Concerts** — Pulls from your Concerts app (shows, gigs). - **Custom** — Add events manually in the admin panel. Go to the **Event Sources** tab and toggle on the sources you want. ### 4. Configure Venues (Per-Location Mode) If you chose **Per Location**, go to the **Venues** tab to select which venues fans can check in at. --- ## Check-In Methods Under the **Check-In** tab, you can enable one or more ways for fans to check in: | Method | How It Works | When to Use | |--------|-------------|-------------| | **Button Tap** | Fan taps a "Check In" button | Always — simplest option, great as a fallback | | **QR Code Scan** | Fan scans a QR code at the venue | When you have printed QR codes at the event | | **Photo Check-In** | Fan takes a photo to prove attendance | When you want visual proof or social content | | **Geolocation** | App verifies the fan's GPS location | When you need verified, location-based check-ins | **Tip:** Enable at least **Button Tap** so fans always have a way to check in. ### Check-In Window Set how many minutes **before and after** an event fans can check in. Some suggestions: - Sports match: **120 minutes** (2 hours) - Concert: **180 minutes** (3 hours) - Day-long festival: **720 minutes** (12 hours) - Multi-day event: **1440 minutes** (24 hours) ### Verification Turn on **Require Verification** if you want check-ins to be validated by geofence proximity or QR scan. Leave it off for a more relaxed, trust-based experience. --- ## Geofence Settings Under the **Geofence** tab, you can set up a virtual boundary around your venue: 1. **Enable Geofence** — Toggle on. 2. **Set Coordinates** — Enter the latitude and longitude of your venue centre. 3. **Set Radius** — How far from the centre a fan can be and still check in. - Stadium/arena: **200m** - Large outdoor venue: **500m** - Festival grounds: **1000m** - Small venue/bar: **100m** 4. **Enforce Strict** — When on, fans *must* be inside the geofence. When off, it's a soft check (warns but allows). --- ## QR Codes The **QR Codes** tab lets you generate printable QR codes for your events or venues. Fans scan these with their phone camera to check in instantly. - In **Per Event** mode, generate a QR per event. - In **Per Location** mode, generate a QR per venue. Print them on posters, programmes, or display them on screens at the venue. --- ## Gamification Under the **Gamification** tab, you control the reward system: | Feature | What It Does | |---------|-------------| | **Points** | Fans earn points for each check-in (10 base points, with bonuses for photos, QR scans, away events, and streaks) | | **Streaks** | Tracks consecutive event attendance — fans lose their streak if they miss one | | **Badges** | Milestone awards: First Timer, Regular (5), Dedicated (10), Superfan (25), Legend (50) | | **Leaderboard** | Public ranking of top attendees by points | You can toggle each feature independently. Turning off the main **Enable Gamification** switch disables all sub-features at once. ### Points Breakdown | Action | Points | |--------|--------| | Check in | 10 | | Photo bonus | +5 | | QR scan bonus | +3 | | Away event bonus | +15 | | Early bird (before event starts) | +5 | | Streak multiplier | +10% per consecutive event | --- ## Reports The **Reports** tab shows attendance analytics: - **Total Check-Ins** across all events - **Unique Attendees** (how many different fans) - **Check-In Method Distribution** (which methods are most popular) - **Top Attendees** with their streaks and points Use this data to understand fan engagement and adjust your settings accordingly. --- ## Day-to-Day Usage (Fan Experience) Here's what fans see and do: 1. **Open the app** — They see upcoming events (or venues) with check-in buttons. 2. **Check in** — Tap the button, scan a QR code, take a photo, or let geolocation verify them. 3. **Earn rewards** — Points are awarded instantly; badges unlock at milestones. 4. **View stats** — Fans see their total check-ins, current streak, points, and badges. 5. **Leaderboard** — They can compare their ranking with other fans. --- ## FAQ & Troubleshooting **Q: Fans say they can't check in.** Check that the check-in window is open (the event must be within the configured minutes before/after). Also verify that at least one check-in method is enabled. **Q: A fan checked in but didn't get points.** Ensure **Enable Points** is toggled on under Gamification. **Q: The leaderboard isn't showing.** Make sure **Enable Leaderboard** is turned on in Gamification settings. **Q: Geofence is rejecting fans who are at the venue.** Try increasing the geofence radius. GPS can be inaccurate indoors — consider 200m+ for most venues. If **Enforce Strict** is on, fans outside the radius are completely blocked; turn it off for a softer approach. **Q: No events are appearing for fans.** Check that at least one data source is enabled in the **Event Sources** tab, and that the source app has upcoming events configured. **Q: Can fans check in to the same event twice?** In **Per Event** mode, no — each fan gets one check-in per event. In **Per Location** mode, yes — repeat visits are allowed and encouraged. **Q: How do I change settings after initial setup?** Just go back to any tab in the admin panel and adjust. Changes take effect immediately. --- ## Widget The Attendance Tracker includes an embeddable **widget** that can appear on portal pages and dashboards. It shows: - Next upcoming event - Check-in count and streak - Quick stats (points, badges) The widget comes in three sizes: **compact** (single line), **standard** (card with stats), and **large** (expanded event list). Your platform administrator can configure which size appears where. --- *Need more help? Contact your SmartLinks platform administrator or check the SDK documentation.* --- # App module: Operating Procedures and Guides Source: https://docs.smartlinks.app/setup/apps/sops Learn to create and manage mobile-friendly SOPs and step-by-step guides for setup, safety, and inspections using the Smartlinks admin console. # SOPs & Guides — User & Admin Guide A friendly walkthrough for brand managers, operations teams, and admins. No technical knowledge required. --- ## What this app does SOPs & Guides lets you create step-by-step instructions for the people who use your products — customers, technicians, staff, or partners. Each guide is a short, mobile-friendly checklist that walks someone through a task: setting up a new appliance, performing a safety inspection, completing a daily cleaning routine, or running through a troubleshooting flow. Use it when you want to: - Make sure a procedure is followed the same way every time. - Capture proof that a task was done (photos, notes, signatures). - Track who completed what, and when. - Trigger follow-up actions automatically when a guide is finished — for example, marking a product as "set up" or "inspected." End users open guides from the public portal on their phone (typically after scanning a QR code or NFC tag on a product) and tap through each step. You manage everything from the admin console. --- ## Getting started ### 1. Open the admin console Open the admin URL provided by your SmartLinks platform. It will look something like: ``` your-app.com/admin.html#/?collectionId=your-collection&appId=apps_sop ``` If you see a yellow **"Missing Configuration"** warning, the URL is missing the collection or app ID — ask your platform administrator for the correct link. ### 2. Configure global settings Click the **Settings** (⚙️) button in the top-right corner of the admin dashboard. A panel slides out with two sections: - **Help Link** — a contextual help button shown to end users while they work through a guide. - **Migration Tool** — a one-time utility for older data; you can ignore it for new setups. #### Help Link settings | Setting | What it does | |---|---| | **Enabled** | Turns the help button on or off across all guides. | | **Help Text** | The phrase shown above the button (e.g. *"Getting stuck? Need help?"*). | | **Action Type** | What happens when the user taps the button: **Phone** (calls a number), **Link** (opens a URL), **Video** (opens a video call link), or **Appointment** (opens a booking link). | | **Action Label** | Button text (e.g. *"Call Support"* or *"Book a Visit"*). | | **Action Value** | The phone number or URL the button uses. | Click **Save** when finished. Help link settings apply to every guide in this collection. ### 3. Create your first guide Back on the dashboard, click **Create Guide** and fill out the form (covered in detail in the next section). --- ## Creating a guide Guides are built in two parts: the **guide overview** (shared info, behavior, and image), then the **steps** that make up the procedure. ### Guide overview fields | Field | Required | What it does | |---|---|---| | **Title** | Yes | The name shown to end users on the guide card. | | **Category** | No | A grouping label (e.g. *Setup*, *Training*, *Troubleshooting*, *Safety*). Helps users browse. | | **Difficulty Level** | Yes | **Beginner**, **Intermediate**, or **Advanced**. Shown as a colored badge (green / yellow / red) on each guide card. | | **Estimated Duration (minutes)** | Yes | How long the guide should take. Shown to users so they know what to expect. | | **Description** | No | One or two sentences describing what the guide covers. | | **Tags** | No | Free-text labels for search and filtering. Type and press Enter to add. | | **Applies To (Group Tags)** | No | Restrict the guide to certain product types (e.g. *TV*, *Fridge*). Only options defined on your collection appear. If you don't see your product types, ask your platform admin to add them. | | **Active** | Yes | When **off**, the guide is hidden from the public portal. Use this to draft, pause, or retire a guide without deleting it. | | **Requires Approval** | No | When **on**, completed runs of this guide need a supervisor sign-off before being recorded as "done." | | **Track Individual Steps** | No | When **on**, each step completion is recorded individually — useful for audits or compliance. When **off**, only the overall completion is recorded. | | **Hero Image** | No | A square image shown on the guide card. Drag-drop or click to upload. | ### On Completion settings Below the main fields, the **On Completion** panel decides what happens when a user finishes the guide. | Setting | What it does | |---|---| | **Update Unique Ownership Record** | When on, finishing the guide writes a value back to the product's record (so you can track "this unit has been set up" across your whole fleet). | | **Variable Name** | The label to set (e.g. *setup*, *installed*, *configured*). Suggestions are offered. | | **Value Type** | Whether to record a **true/false**, a **date**, a **date and time**, or a free **text** value. | | **Value** | What to actually record (e.g. `true`, today's date, or *completed*). | | **Skip Confirmation Message** | Optional message shown if a user tries to skip the guide — explains why they should complete it. | Click **Save Guide**. You'll be returned to the dashboard, where the new guide appears in the list. ### Adding steps Click your new guide on the dashboard to open the **detail view**, then go to the **Steps** tab. Click **Add Step** and fill in: | Step field | What it does | |---|---| | **Title** | A short name for the step (e.g. *"Plug in the device"*). | | **Description** | One-line summary shown beneath the title. | | **Instructions** | The full instructions, with rich formatting, images, and videos. Use the toolbar to add bold, lists, and media. | | **Step Number** | The order the step appears in. Auto-filled but editable. | | **Expected Duration (minutes)** | How long this single step should take. | | **Primary Image** | A featured image for this step (separate from images embedded in instructions). | | **Warning Text** | A red warning banner shown above the step (e.g. *"Disconnect power before opening the panel"*). | | **Success Criteria** | A short note describing what "done" looks like. Helps users confirm they did it right. | | **Requires Photo** | If on, the user must upload a photo before continuing. | | **Requires Signature** | If on, the user must sign on screen before continuing. | | **Requires Notes** | If on, the user must type a note before continuing. | Save the step. Repeat for each step in the procedure. You can reorder, edit, or delete steps from the Steps tab at any time. ### The other tabs Once a guide exists, the detail view has five tabs: | Tab | Purpose | |---|---| | **Setup** | Edit the guide overview fields (same form as creation). | | **Steps** | Add, edit, reorder, and delete the steps inside this guide. | | **Analytics** | See how many people started, completed, or abandoned the guide. | | **Executions** | Browse a history of every time someone ran this guide, with timestamps, duration, photos, and notes. | | **Broadcasts** | Send a notification to users prompting them to complete the guide. | --- ## Day-to-day usage (end-user view) End users typically arrive at the public portal by scanning a QR code or tapping an NFC tag on the physical product. Here's what they see: 1. **Home screen** — A list of guide cards relevant to their product, plus an **Outstanding Tasks** panel at the top showing anything still owed. 2. **Guide card** — Title, hero image, category icon, difficulty badge (green/yellow/red), and estimated time. 3. **Tap a guide** — They see the description and a **Start** button. 4. **Step-by-step flow** — One step at a time, with progress shown across the top. They read instructions, view photos/videos, and meet any requirements (photo, signature, notes) before tapping **Next**. 5. **Help button** — If you've enabled it in settings, a help button appears on every step. 6. **Completion** — On the final step they get a confirmation, and any **On Completion** action you configured fires automatically. Sessions are saved in the browser, so if a user closes the page mid-guide they can pick up where they left off. --- ## Widgets This app exposes one widget that other apps in your SmartLinks portal can embed. | Widget | What it shows | Sizes | |---|---|---| | **Example Widget** | A welcome card with the user's name and a button that opens the full SOPs & Guides app. | **Compact** (small card, button only), **Standard** (default), **Large** (full detail) | Your platform team chooses where and how to embed it. --- ## Managing guides and runs ### From the dashboard list - **Search and filter** — find a guide by title, category, or tag. - **Click a guide** — opens the detail view. - **Edit** — change any setting on the Setup tab. - **Activate / deactivate** — flip the **Active** switch on the Setup tab to hide a guide from end users without deleting it. - **Duplicate / archive / delete** — available from the guide's action menu. ### Reviewing executions On the **Executions** tab of any guide you'll see every run with: - Who ran it (where identifiable). - Start and end time. - Total duration. - Each step's photos, signatures, and notes. - Whether it was completed, abandoned, or is still in progress. ### Broadcasting Use the **Broadcasts** tab to push a notification to a group of users telling them to complete the guide. You'll be able to choose the audience and the channel (your platform decides which channels are available — typically push, email, or wallet pass). --- ## Bulk import You can create many guides at once by importing a CSV. ### CSV template ```csv title,description,category,steps "Daily Safety Inspection","Walk through every safety checkpoint","Safety","[{""title"":""Check fire extinguishers"",""description"":""Verify all extinguishers are charged and unobstructed"",""required"":true},{""title"":""Test smoke detectors"",""description"":""Press the test button on each unit"",""required"":true}]" "Opening Procedure","Morning opening tasks","Operations","[{""title"":""Unlock entrance"",""description"":""Use master key from the safe"",""required"":true},{""title"":""Turn on lights"",""description"":""Main panel in utility room"",""required"":true}]" "Coffee Maker Setup","First-time installation","Setup","[{""title"":""Unbox and place"",""description"":""Place on a flat, dry surface near a power outlet"",""required"":true},{""title"":""Fill water tank"",""description"":""Use cold filtered water up to the MAX line"",""required"":true},{""title"":""Run a cleaning cycle"",""description"":""Press and hold the clean button for 3 seconds"",""required"":true}]" ``` ### Field reference | Column | Required | Type | Notes | |---|---|---|---| | **title** | Yes | Text | Max 200 characters. Becomes the guide title. | | **description** | No | Text | Max 1,000 characters. | | **category** | No | Text | Free text — used for grouping on the public portal. | | **steps** | Yes | JSON | A JSON array of step objects. Each needs a `title`; `description` and `required` are optional. Make sure all double-quotes inside the JSON are escaped (`""`). | After import you'll see a summary: *"Imported X of Y guides successfully."* Any rows with broken JSON in the **steps** column are skipped — see the FAQ below. --- ## FAQ & troubleshooting **Why can't users see my guide?** Check that **Active** is on (Setup tab). If you used **Applies To**, the user's product must match one of those tags. If neither matches, the guide is hidden. **Why is my CSV import skipping rows?** The most common cause is broken JSON in the **steps** column. Open the row in a plain-text editor and confirm: every `"` inside the JSON is escaped as `""`, the array starts with `[` and ends with `]`, and every step object has at least a `title`. Re-import the corrected rows. **A user reports their progress was lost.** Progress is saved in their browser. If they cleared cookies, switched devices, or used a private/incognito window, the saved session is gone. They'll need to start the guide again. **The "Help" button isn't appearing.** Open **Settings** → **Help Link** and confirm **Enabled** is on, **Action Value** is filled in (number or URL), and you clicked **Save**. **Can I change a guide after launch?** Yes. Edits take effect immediately for new runs. Runs already in progress keep the version they started with until completed. **Can I delete a guide that's been used?** We recommend turning **Active** off instead of deleting — that way past execution history stays intact and reportable. If you do delete a guide, its executions and analytics are removed too. **Group Tags / "Applies To" is empty.** Your collection doesn't have product types defined yet. Ask your platform admin to add a `groupTags` list (e.g. `["TV", "Fridge", "Washing Machine"]`) to your collection settings. **A step requires a photo but the camera won't open.** The user's browser must have camera permission for your site. On iPhone, Safari prompts on first use; if they tapped **Don't Allow**, they need to re-enable camera access in Settings → Safari → Camera. On Android, the prompt appears in Chrome the first time. **"On Completion" didn't update my product record.** Confirm **Update Unique Ownership Record** is on, the **Variable Name** is set, the **Value Type** matches what you expect, and the user fully completed the guide (not skipped). Skipped guides do not trigger completion actions. **A user says they need to redo a guide they already completed.** Currently each completion is recorded as a separate run. They can simply start the guide again from the public portal — the new run won't overwrite the old one. --- # App module: Cooking Guide Source: https://docs.smartlinks.app/setup/apps/cookingGuide Learn how to create and manage digital cooking instructions, including building reusable guide sets, adding multi-method cooking steps, and assigning them to products. # Cooking Guide — User & Admin Manual A friendly, end-to-end manual for brand managers, marketing teams, and product admins. No technical knowledge required. --- ## 1. What this app does The Cooking Guide app replaces the small cooking instructions printed on food packaging with a digital, interactive experience. Customers scan a product, pick the cooking method they want to use (oven, microwave, air fryer, etc.) and walk through clear, step-by-step instructions with built-in timers, typed callouts (tips, cautions, "best results", "good to know") and per-appliance variant adjustments. It is designed for: - **Brand managers** who want clearer, richer, multi-method instructions than packaging allows. - **Marketing teams** who want to update cooking guidance without reprinting packaging. - **Customers** who want a guided, mistake-proof cooking experience on their phone — in their language. The clever bit: you build **reusable guide sets** at the facet level, then assign them to products. Many products can share the same guide — perfect for ranges, variants or seasonal launches with identical cooking instructions. Single products can have their own overrides too. --- ## 2. Getting started — first-time setup 1. Open the **admin console** for your collection (your platform will provide the link, which includes the collection ID and app ID). 2. You'll land on the **Cooking guides** admin shell. The screen is split into three areas: - **Left rail** — browse existing guides. The tabs at the top let you switch between **Facet** scope (guides shared across many products) and **Product** scope (guides for a single product). - **Editor** (middle) — the form for the currently-selected guide. - **Preview** (right pane) — a live preview of how customers will see the guide, including the variant picker and starting-state toggle when those are configured. 3. Pick the scope you want to author at — usually start with **Facet** so the same guide can be reused across a whole product range. Click **+ New** to create your first guide. 4. To create a product-specific override, switch to the **Product** tab and pick the product from the list. > **Tip:** Build a facet-level "master" guide first, save it, then preview it on a test product before building product-specific overrides. Product-level guides automatically override facet-level guides for that product. > **Assigning products to facets:** product → facet membership is managed in the SmartLinks console for now. Once a product carries the right facet, it inherits any guide saved at that facet scope automatically — no extra step needed in this app. --- ## 3. Creating records ### 3a. Creating a guide (left rail) Pick a scope (Facet or Product), then click **+ New** in the rail. Choose the target (a facet value or a product) and the editor opens immediately so you can add cooking methods. Use the search box at the top of the rail to filter long lists. ### 3b. Adding cooking methods to a guide Inside a guide, you can add one or more **cooking methods**. Each method is a complete, standalone set of instructions for one way of cooking the product. Methods are added via the **Add Method** dialog, which shows colour-coded method tiles matching what customers see. The available methods are: | Method | Best for | |---|---| | **Oven** | Conventional or fan oven | | **Microwave** | Microwave cooking | | **Air Fryer** | Air fryer or air-crisp function | | **Grill** | Grill or broiler | | **Hob / Stovetop** | Pan on hob or stovetop | | **BBQ** | Outdoor barbecue | | **Steamer** | Steam cooking | | **Slow Cooker** | Slow cooker / crock pot | | **Pressure Cooker** | Pressure cooker / Instant Pot | | **Toaster Oven** | Compact toaster oven | You can add the **same method more than once** with a different **starting state** (e.g. one Oven guide for chilled, another for frozen). The public viewer shows a chilled/frozen toggle when both are present. For each method you'll fill in: #### Overview Settings | Field | What it does | |---|---| | **Starting state** | `chilled`, `frozen`, or none. Drives the chilled/frozen toggle on the public viewer. | | **Servings** | Free text, e.g. "Serves 2–3". Shown to customers at the top of the guide. | | **Prep Time (min)** | How long preparation takes before cooking starts. Optional. | | **Total Time (min)** | Total cooking time end-to-end. Shown prominently to customers. Optional. | | **Enabled** (toggle) | Turn the method on or off without deleting it. Disabled methods don't appear to customers. | #### Variants (appliance grades) Most methods have a set of sensible default **variants** customers can pick between — the public viewer shows pill buttons and recalculates times/temperatures accordingly: | Method | Default variants | |---|---| | **Microwave** | 700W / 800W / 900W / 1000W (time factor) | | **Air Fryer** | 1400W / 1700W / 2000W (time factor) | | **Oven** | Fan / Conventional / Gas (with temperature offsets) | | **Hob / Stovetop** | Gas / Electric / Induction | | **Grill** | Medium / High | You can edit these per guide, set a default variant, and use **per-variant overrides** on any individual step (collapsed by default; a badge appears when overrides are set). If a variant doesn't change anything for a given step, leave the overrides blank — the base value applies. #### Cooking Steps Add as many steps as you need. Each step has: | Field | What it does | |---|---| | **Instruction** | Rich text (bold, italic, lists) the customer reads. Required. | | **Duration (min)** | If set, the customer gets a built-in timer for this step. | | **Temperature** | A number, e.g. `200`. Optional. Omit on methods like hob, grill, microwave where grades replace it. | | **Unit** | °C or °F. Defaults to °C. | | **Per-variant overrides** | Override duration and/or temperature for specific appliance grades. | | **Callouts** | Add one or more typed callouts via the dropdown: **Tip**, **Caution**, **Best results**, **Good to know**. Each has a rich-text body and renders with its own colour and icon. | Click **Add Step** to add another. Steps are automatically numbered and renumbered if you delete one. > **Tip:** Most product guides need 3–6 steps. If you're getting to 10+, consider whether some can be combined. > Legacy single `tip` / `warning` fields from older guides are automatically upgraded to typed callouts on load — you don't need to migrate anything by hand. ### 3c. Saving and editing later The guide editor saves when you close it. Reopen any guide from the rail to edit, add methods, or change steps at any time. Changes go live immediately. --- ## 4. Day-to-day usage (what your customers see) When a customer scans a product (or opens a product link), they see the public Cooking Guide experience: 1. **Method picker** — Colour-coded method tiles for each enabled method, with cook time, temperature (if applicable) and a "N settings" pill where variants exist. 2. **Chilled / frozen toggle** — If a method has both starting states configured. 3. **Variant picker** — Pill buttons (e.g. "800W", "Fan oven"). Times and temperatures recalculate live. 4. **Step-by-step viewer** — One step at a time, with: - The instruction. - A built-in **timer** if the step has a duration (with haptic feedback on completion). - Typed **callout cards** (tip / caution / best results / good to know). - **Previous / Next** buttons and a progress bar with step pips. 5. **Complete** — When they finish the last step, they see a celebratory completion screen. 6. **Start Over** — They can reset progress at any time. The experience is mobile-first, with smooth animations and large tap targets. It also respects the customer's language (English, German, French) — both the static UI strings and the admin-authored content (product name, overview, step text) are translated automatically. --- ## 5. Widgets You can embed a small **Cooking Widget** anywhere your platform supports widgets. It shows a quick summary of the cooking methods available for a product with a single tap-through to the full guide. The widget comes in three sizes: | Size | What it shows | |---|---| | **Compact** | Method icons and names only. Up to 3 methods, then "+ N more". Best for tight spaces like product cards. | | **Standard** *(default)* | Method icons, names and total cook times. Up to 5 methods. | | **Large** | Everything in Standard plus temperatures. Up to 10 methods. | All sizes include a **View Instructions** button that opens the full step-by-step experience. The widget honours the `lang` prop and translates admin content the same way the full viewer does. The widget pulls its data automatically from whatever guide you've assigned to the product — no extra setup once the guide is live. --- ## 6. Managing records ### Editing a guide Click any guide in the left rail to open the editor. Changes save and go live immediately. Every product assigned to that guide updates instantly. ### Disabling a method In the editor, toggle **Enabled** off for a method. It will no longer appear to customers, but you don't lose the steps — turn it back on any time. ### Deleting a guide Click the delete icon next to a guide in the rail. You'll be asked to confirm. **Warning:** Any product currently assigned to that guide will lose its cooking instructions until you assign a new one. ### Assigning a guide to a product Product assignment happens via facets — when a product carries the facet value tied to a guide, it inherits that guide. Product-scope guides override facet-scope ones for that specific product. --- ## 7. Import / bulk setup If you have lots of products to configure at once, you can import cooking instructions in bulk via your platform's import flow. Each row in the CSV defines one cooking method for one product. ### CSV field reference | Field | Required | Type | Notes | |---|---|---|---| | `productId` | ✅ | text | The product's ID in the platform. | | `productName` | — | text | Optional display name override shown to customers. | | `methodId` | ✅ | text | One of: `oven`, `microwave`, `air_fryer`, `grill`, `hob`, `bbq`, `steamer`, `slow_cooker`, `pressure_cooker`, `toaster_oven`. | | `startingState` | — | text | `chilled`, `frozen`, or empty. | | `servings` | — | text | E.g. "Serves 2". | | `prepTime` | — | number | Prep time in **seconds**. | | `totalTime` | — | number | Total cook time in **seconds**. | | `steps` | ✅ | JSON | Array of step objects (see below). | Each step inside `steps` can include: `order`, `instruction` (required, Markdown), `duration` (seconds), `temperature`, `temperatureUnit` (`C`/`F`), `callouts` (array of `{type, body}` where `type` is `tip`/`caution`/`best_results`/`good_to_know` and `body` is Markdown). Legacy `tip` and `warning` strings are still accepted and auto-upgraded. ### CSV example ```csv productId,productName,methodId,startingState,servings,prepTime,totalTime,steps prod_001,Margherita Pizza,oven,chilled,2 servings,60,1500,"[{""order"":1,""instruction"":""Preheat oven to **200°C** (fan)"",""temperature"":200,""temperatureUnit"":""C""},{""order"":2,""instruction"":""Place pizza on middle shelf"",""callouts"":[{""type"":""best_results"",""body"":""Place directly on the rack for a crispier base""}]},{""order"":3,""instruction"":""Cook 12–15 minutes until golden"",""duration"":780,""callouts"":[{""type"":""caution"",""body"":""Do not leave unattended""}]}]" prod_001,Margherita Pizza,microwave,chilled,1 serving,,180,"[{""order"":1,""instruction"":""Pierce film lid several times""},{""order"":2,""instruction"":""Heat on high for 3 minutes"",""duration"":180},{""order"":3,""instruction"":""Stand 1 minute before serving"",""duration"":60,""callouts"":[{""type"":""caution"",""body"":""Filling will be very hot""}]}]" ``` > **Tip:** Group rows by `productId`. Multiple methods for the same product are merged into a single guide automatically. Use multiple rows with the same `methodId` and different `startingState` values to offer chilled/frozen variants of the same method. --- ## 8. FAQ & troubleshooting **Why can't customers see any cooking instructions?** Three common causes: (1) the product has no guide assigned (no matching facet or product override); (2) the assigned guide has no methods enabled — open the guide and toggle at least one method on; (3) the only enabled methods have no steps — every method needs at least one step. **I changed a guide. Why don't customers see the update?** Updates are live instantly. Ask the customer to refresh the page or rescan the product. **Why do hob, grill and microwave methods not show a temperature?** They use **variant grades** instead — gas mark / electric / induction for hob, medium / high for grill, wattage for microwave. Pick the customer's appliance from the variant pills and times adjust accordingly. **My CSV import skipped some rows. Why?** Most likely causes: missing required fields (`productId`, `methodId`, `steps`), an invalid `methodId`, or malformed JSON in the `steps` column. Check the import report for line numbers. **Can two products share the same cooking instructions?** Yes — facet-scoped guides are shared across every product carrying that facet value. **What happens if I delete a guide that's in use?** The guide is removed and any product assigned to it loses its cooking instructions until you assign a new one. The product itself isn't affected. **Can I temporarily hide a cooking method without losing my work?** Yes — toggle **Enabled** off on that method inside the editor. **Do I have to fill in temperatures and times?** No. Only the **instruction** is required on each step. Everything else is optional, but the more you fill in, the better the customer experience. **Does the app support other languages?** Yes — English, German and French are supported automatically for both the static UI and the admin-authored content you write. Translations are cached after first fetch. **Why are durations stored in seconds in the CSV but minutes in the editor?** The editor uses minutes for convenience. The CSV uses seconds for precision — multiply minutes by 60. --- ## Future surfaces This app does not currently ship a MobileAdminContainer. The Cooking Guide is a consumer-facing reveal experience — there is no field/operator workflow that requires NFC, RFID, QR scanning or on-device hardware. --- # App module: Content Pages Source: https://docs.smartlinks.app/setup/apps/contentPages Create and publish custom, block-based digital twin pages for your brand. This guide covers using content blocks, section styling, and AI tools to build rich mobile experiences. # Content Pages — User Guide Welcome to **Content Pages**, the SmartLinks microapp that lets you build and publish rich content pages for your brand's digital twin experiences. --- ## Overview Content Pages is a block-based content management system built into the SmartLinks platform. You can create pages with headings, rich text, images, galleries, hero banners, call-to-action buttons, accordions, tables, timelines, and more — all without any coding. Pages you publish appear automatically in the SmartLinks portal alongside your products and proofs, giving customers useful information exactly when they need it. --- ## Getting Started ### First-Time Setup 1. **Open the Admin Panel** — Navigate to your collection in the SmartLinks Admin Console and open the Content Pages app. 2. **Create Your First Page** — Click **New Page**, give it a title and a slug (URL-friendly name like `about-us` or `care-guide`). 3. **Add Content Blocks** — Use the block toolbar to add headings, text, images, and other content types. 4. **Publish** — Set the page status to **Published** when you're ready for customers to see it. ### Configuration Settings In the **Settings** tab you can adjust: | Setting | Description | |---------|-------------| | **App Title** | The display name for your content pages app | | **Default Language** | The primary language for your content (default: English) | | **Notifications** | Whether to notify users when new content is published | --- ## Creating Content ### Available Block Types | Block | What It Does | |-------|-------------| | **Heading** | Section titles (H1, H2, H3) with styles: plain, underline, accent, gradient | | **Rich Text** | Formatted paragraphs with bold, italic, links, and lists | | **Image** | Single images with layout options: full-width, captioned, rounded, parallax | | **Hero** | Large banner sections with background image, title, subtitle, and CTA button | | **Gallery** | Multiple images in grid, carousel, or masonry layout | | **Video** | Embedded video from YouTube, Vimeo, or direct URL | | **Feature Grid** | 2–4 column grid of features with icons and descriptions | | **Call-to-Action Banner** | Highlighted banner with button link | | **Accordion** | Expandable FAQ-style sections | | **Callout** | Highlighted box for tips, warnings, or important information | | **Stats** | Numeric statistics with labels | | **Quote** | Styled blockquote with attribution | | **Table** | Data tables with headers | | **Tabs** | Tabbed content sections | | **Timeline** | Chronological events in vertical or alternating layout | | **Divider** | Visual separator between sections | | **Spacer** | Adjustable vertical spacing | | **Embed** | Embed external content (iframes) | | **Widget** | Embed another SmartLinks widget inline | ### Section Styling Every block can have section-level styling applied: - **Background** — None, light, dark, accent, muted, gradient, custom colour, or background image/video - **Padding** — None, small, medium, large, extra-large - **Text Colour** — Auto (adapts to background), light, or dark - **Animation** — Fade in, slide up, slide left, zoom in, or none - **Border** — Optional top/bottom borders with solid, dashed, or dotted styles ### AI Content Assistant The admin panel includes an AI chat assistant that can help you: - **Generate content** — Describe what you want and the AI creates blocks for you - **Generate images** — Ask for images and review them before they're saved - **Translate pages** — Automatically translate content into other languages - **Edit existing content** — Ask the AI to rewrite, expand, or restructure your pages To use it, open the AI panel in the page editor and describe what you'd like. --- ## Multi-Language Support Content Pages supports multiple languages: 1. **Set your default language** in Settings (typically English). 2. **Open a page** in the editor. 3. **Switch language** using the language panel. 4. **Translate** — Edit translations manually or use the AI assistant to auto-translate. Translations are stored separately from the source content. Visitors see content in their preferred language automatically based on the `?lang=` URL parameter. Supported languages: English, German, French (more can be added). --- ## Page Management ### Page Properties | Property | Description | |----------|-------------| | **Title** | The page's display title | | **Slug** | URL-friendly identifier (e.g. `care-guide`) — used as `?pageId=care-guide` | | **Status** | `draft` or `published` — only published pages are visible to customers | | **Show in Listing** | Whether the page appears in the page directory | | **Deep Linkable** | Whether other apps and AI can discover and link to this page | | **Page Style** | Optional background and padding for the entire page | ### Page Listing When no specific page is selected, customers see a directory of all published pages. If only one page is published, customers are taken directly to it. --- ## Embedding Content Pages Content Pages can be displayed in three ways: ### 1. Iframe (Default) The standard SmartLinks portal embeds the app in an iframe. No extra setup needed. ### 2. Container (SEO-Friendly) For platforms that need the content in the DOM (for SEO), use the **ContentPageComponent** container: ``` CollectionId + AppId → loads all published pages CollectionId + AppId + PageId → loads a specific page ``` The container inherits the parent platform's theme and renders directly in the page — no iframe. ### 3. Widget A lightweight widget card can show a preview/summary. Widgets load immediately and are ideal for dashboards showing multiple apps. --- ## Deep Linking Published pages with **Deep Linkable** enabled are automatically registered so other SmartLinks apps, the portal navigation, and AI assistants can link directly to them. For example, if you publish a page with slug `care-guide` and enable deep linking, other apps can navigate to it using: ``` { appId: 'content-pages', deepLink: 'care-guide' } ``` --- ## FAQ & Troubleshooting **Q: My page isn't showing up for customers.** A: Check that the page status is set to **Published** and **Show in Listing** is enabled. **Q: Images aren't loading.** A: Ensure images were saved as assets (confirmed in the AI flow) rather than using temporary URLs. **Q: Translations aren't appearing.** A: Verify the translation exists for the target language and that the `?lang=` parameter matches (e.g. `de` for German). **Q: The page looks different in the portal vs the admin preview.** A: The portal applies the brand's theme colours. Check that your section styles use theme tokens (accent, muted, etc.) rather than hard-coded colours. **Q: How do I reorder blocks?** A: Use the drag handles on the left side of each block in the editor, or use the up/down arrows in the block toolbar. **Q: Can I duplicate a page?** A: Not yet directly — but you can ask the AI assistant to "create a new page based on [existing page]" and it will copy the structure. --- # App module: NPS Rating Source: https://docs.smartlinks.app/setup/apps/nps Learn how to collect and analyze customer feedback using the NPS Rating app. Track scores, segment results by product facets, and configure targeted survey questions. # NPS Score — User Guide The NPS Score app collects Net Promoter Score feedback from your customers and turns it into actionable insights — overall, over time, per question, and broken down by **facets** like region, product tier, or category. --- ## What you can do - **Ask one or many NPS questions** at the collection level or for specific products. - **See your NPS score, response counts, and a 0–10 distribution** in the **Results** tab. - **Track NPS over time** with rolling-average and response-volume charts in the **Trends** tab. - **Slice scores by any facet** (e.g. "What's our NPS for Premium products in Napa?") in the Segments tab. - **Filter Results by facet** to drill into specific cohorts. - **Target questions to specific facet values** so a question only appears for, say, Premium-tier products. - **Customize the thank-you and "already submitted" messages** shown to respondents. - **Run the survey in any language** — UI strings and your custom copy are auto-translated based on the visitor's language. --- ## Getting started 1. Open the admin dashboard for the NPS app. 2. Go to **Settings**. 3. Set your **page heading** (and optional subheading). 4. Edit the default question — or add more. 5. (Optional) Customize the **thank-you** and **already-submitted** messages. 6. Click **Save Configuration**. The public survey is now live for any visitor scanning a product or visiting the collection. Need help? The **?** icon in the dashboard header opens a quick description and a link to this guide. The **↗** icon opens the guide directly in a new tab. --- ## Day-to-day usage (for your customers) Customers see a clean 0–10 score picker with one or more questions. They tap a number, hit submit, and see a thank-you. That's it — under 10 seconds. If a customer returns to a survey they've already completed, they see your **already-submitted** message instead of being able to submit again. Behind the scenes, when a customer submits a score on a product page, we automatically record: - The score (0–10) - The product ID - **Every facet assigned to that product** (region, tier, category, etc.) This means you can analyze NPS by facet *historically* — no need to set anything up first, as long as facets were assigned to the product before the response came in. --- ## The Results tab Shows a single question's overall NPS: - Big NPS number (–100 to +100) - Distribution bar showing the proportion of low / mid / high scores in vibrant colors - Per-score breakdown (0 through 10 with response counts) Use the **Filter** button to narrow by any facet — e.g. "show me NPS just for Reds in California." If you have multiple questions, click the question buttons at the top to switch. --- ## The Trends tab See how your NPS is moving over time. - **Rolling NPS line** — a smoothed score over a rolling 30-day window so short-term spikes don't dominate. - **Response volume overlay** — bars for the number of responses in each bucket, layered behind the trend line so you can tell whether a swing is meaningful or just noise from a quiet day. - **Per-question switching** — same question selector as Results. - **Facet filter** — narrow trends to a specific cohort (e.g. only Premium-tier responses). Use this tab to answer "are we getting better or worse?" rather than "where do we stand right now?". --- ## The Segments tab This is where facets shine. 1. Pick a question. 2. Pick a facet to **group by** (e.g. *Region*). 3. See a sorted list of every value in that facet with its NPS score, response count, and distribution bar. **Low-sample warning:** segments with fewer than 5 responses are dimmed — treat their scores with caution. **Empty segments still appear** so you can quickly see where you have no data. This is useful for spotting "we have zero feedback from Australia" gaps. --- ## Question targeting (Settings tab) Want a question that only appears for Premium-tier products? Or only for items in the Wine category? 1. In Settings, find the question. 2. Under **Show only when**, click **Add target**. 3. Pick a facet and value (e.g. *Tier: Premium*). 4. Save. The question now only appears when: - The customer is on a **product page**, AND - That product has the matching facet value. A question with no targets is shown for all products and on collection-level surveys. --- ## Customizing the survey copy In **Settings** you can override the default text shown to respondents: - **Heading** and **subheading** at the top of the survey. - **Thank-you title** and **subtitle** shown right after a successful submission. - **Already-submitted title** and **subtitle** shown if a respondent revisits a completed survey. Leave any field blank to fall back to the built-in defaults. ### Languages and translation You don't need to maintain separate copies per language. The app: - Detects the visitor's language from the parent page (or from a `?lang=` URL parameter). - Auto-translates both the built-in UI strings *and* the copy you authored above into the visitor's language on the fly. - Caches translations in the browser so repeat visits are instant. Author your copy in whichever language is most natural for you — the platform handles the rest. --- ## Setting up facets Facets themselves are managed at the collection level (outside this app — usually in your main brand admin). Once defined, they're automatically available here. Common useful facets for NPS: - **Region** — geography (Napa, Bordeaux, Tuscany) - **Tier** — pricing band (Entry, Standard, Premium, Reserve) - **Category** — product type (Red, White, Sparkling) - **Vintage** — year cohorts - **Channel** — sales channel (DTC, Trade, Retail) --- ## FAQ **Why do some old responses not appear in segments?** Facets are stamped onto each response *at submission time*. Responses recorded before facets were assigned to a product won't appear in segment views. New responses going forward will. **Can a product belong to multiple values of one facet?** Yes — multi-cardinality facets work fine. The response is attributed to *each* matching segment, so a wine in both "Organic" and "Biodynamic" counts toward both NPS scores. **Why is my Segments tab empty even though I have responses?** Either the collection has no facets defined yet, or the relevant products had no facet assignments when responses came in. Assign facets to your products and look at responses going forward. **Why does the Trends line look flat at the start?** The rolling 30-day window needs ~30 days of history before it stabilizes. Early buckets average over fewer days, so the line settles as you collect more responses. **Can I embed the survey somewhere else?** Yes — the app ships an embeddable widget (inline or as a link) that picks up the same configuration, language, and theming as the standalone survey. **Can I export raw responses?** Not from this app yet — coming in a future update. For now you can read events via the platform's interactions API. **What's a good NPS?** Industry varies wildly. As a rough benchmark: - **Above 50** is excellent - **0 to 50** is solid - **Below 0** means more low scores than high scores Always trend over time; absolute numbers matter less than direction. --- ## Future surfaces This app does not currently ship a MobileAdminContainer. NPS is an end-consumer survey + desk-based admin dashboard, with no NFC / QR / camera or in-the-field operator workflow. If such a workflow is added later, follow migration step 12 to scaffold the mobile admin bundle. --- # App module: Mailing List Source: https://docs.smartlinks.app/setup/apps/mailingList Learn how to configure mailing list signup forms, customize data collection fields, and manage automated newsletter subscriptions for your Smartlinks collection. # Mailing List — Admin Guide Welcome! This guide walks you through setting up and managing your mailing list signup form. --- ## Getting Started When you first open the Mailing List settings, you'll see a simple setup screen. Here's what to do: 1. **Give your signup a name** — Something like "Newsletter Signup" or "VIP Club". This is just for your own reference so you can find it later. 2. **Add a short description** — A quick note about what this signup is for (e.g., "Monthly newsletter subscribers"). 3. **Hit Save** — That's it for the basics! --- ## Building Your Signup Form The form editor lets you choose what information you collect from people who sign up. You can mix and match from these field types: | Field Type | What It Does | |---|---| | **Text** | A simple text box (great for names, company, etc.) | | **Email** | An email field with built-in validation | | **Text Area** | A larger box for longer answers | | **Number** | For numeric answers | | **Date** | A date picker | | **Dropdown** | A list of options to choose from | | **Radio Buttons** | Pick one from a set of choices | | **Checkboxes** | Pick multiple from a set of choices | | **Yes/No Toggle** | A simple on/off checkbox | ### Adding a Field 1. Click **Add Field** 2. Pick the type you want 3. Give it a label (what the user sees, e.g., "Your Name") 4. If it's a dropdown, radio, or checkboxes — type in the choices 5. Tick **Required** if people must fill it in ### Reordering Fields Drag and drop fields to change the order. What you see in the editor is what your users will see. ### Removing a Field Click the delete icon next to any field you no longer need. --- ## Writing Your Messages You have two messages to customise: ### Before Signup This appears above the form. Tell people what they're signing up for and why it's worth it. **Good example:** "Get weekly tips and exclusive offers delivered to your inbox." **Avoid:** "Join our mailing list." (too vague!) ### After Signup This appears once someone has signed up. Thank them and let them know what happens next. **Good example:** "Thanks for joining! Check your inbox for a welcome email." > 💡 **Tip:** If AI-generated copy is available, you can use it as a starting point and tweak it to match your brand voice. --- ## Newsletter Subscriptions Want signups to automatically subscribe people to your newsletter or updates? Here's how: 1. **Turn on subscriptions** using the toggle 2. **Pick a topic** from your available list (e.g., "Weekly Newsletter") 3. **Choose how you'll reach them** — Email, SMS, or Push Notifications 4. **Add a consent checkbox** — We recommend always having this on. Write something clear like: *"Yes, send me weekly updates by email"* > 🔒 **Privacy note:** If you have European customers, always use the consent checkbox. It keeps you compliant with data protection rules. --- ## The Signup Widget Your mailing list also comes as a compact widget that can appear in other parts of your site — sidebars, footers, or cards. It comes in three sizes: | Size | What It Shows | |---|---| | **Compact** | Just an email box and a button — perfect for tight spaces | | **Standard** | A title, email field, and button | | **Large** | Title, description, name + email fields, and a link to the full form | The widget handles everything automatically — when someone signs up through it, they get the same experience as the full form. --- ## Checking Your Signups The dashboard shows you: - **Total Signups** — everyone who's ever signed up - **Signups Today** — how many new signups you've had today Use these to track how your signup form is performing and whether any changes you make are having an impact. --- ## Quick Settings You Can Change Anytime These are the things you can tweak without redoing your whole setup: | Setting | What It Does | |---|---| | Pre-signup message | The text shown above the form | | Post-signup message | The thank-you text after someone signs up | | Newsletter subscription on/off | Toggle subscriptions without deleting the config | | Submit button text | Change "Sign Up" to whatever you like | | Success message | The confirmation shown inside the form | Changes go live as soon as you save. --- ## Common Questions **My form isn't showing up** Make sure you've saved your configuration at least once. If it still doesn't appear, check with your platform admin that the app is installed correctly. **Someone signed up but didn't get subscribed** Check that the subscription toggle is turned on and a topic is selected. If it was turned on after they signed up, it won't apply retroactively. **Can I change the form after people have already signed up?** Yes! You can add, remove, or reorder fields at any time. Previous signups keep their original data. **How do I change the look and feel?** The form automatically inherits the styling from your site — colours, fonts, and button styles all match your brand. --- Need more help? Reach out to your platform administrator. --- # App module: Wine Label Source: https://docs.smartlinks.app/setup/apps/wineLabel A guide for wineries and importers to configure the EU Wine E-Label app, covering regulatory compliance, nutrition declarations, and data inheritance models. # EU Wine E-Label — User Guide A friendly walkthrough for vineyards, importers, and brand managers using the **EU Wine E-Label** app to give every bottle a compliant digital label. --- ## 1. What this app does When a consumer scans the QR code or NFC tag on a bottle, the EU Wine E-Label app shows them a clean, mobile-friendly page with everything required by the EU's 2024 wine labelling rules: - Wine identity — name, vintage, colour, category, alcohol % - Grape varieties and tasting notes - Producer and bottler details - Storage guidance - Certifications (Organic, Biodynamic, PDO, PGI, Vegan, …) The page is automatically translated into the consumer's language and inherits the look and feel of whatever surface it appears on. > **Allergens, ingredients and nutrition declarations** are handled by > **separate dedicated SmartLinks apps** installed alongside this one. Each > presents its data according to the relevant legislation. **Who it's for:** vineyards, négociants, importers, and distributors who need to comply with EU labelling rules without hand-building a label page for every SKU. --- ## 2. The big idea — set it once, override only when needed Wine information cascades through three layers, lowest priority first: 1. **Vineyard** — winery name, address, certifications. Set **once** for your entire catalogue. 2. **Wine facts (defaults & rules)** — the label info itself, set either: - as a **global** default that applies to every bottle, or - as a **rule** that applies to a group of wines (e.g. *"all reds"* or *"all Burgundy Pinot Noirs"*). 3. **Product overrides** — only the fields that genuinely differ for *this* specific wine (vintage, lot number, exact ABV). Anything you set at a more specific layer **wins** over the layers below it. You only ever enter common information once. --- ## 3. First-time setup (5 minutes) ### Step 1 — Set up your vineyard Open the admin. The first thing you'll see is a yellow banner inviting you to **Set up your vineyard**. Click it. Fill in: | Field | Required | Notes | |-------|----------|-------| | Winery Name | ✅ | Shown above every wine label | | Producer Identifier | — | Official EU bottler/producer ID (e.g. `FR-21-001-042`) | | Description | — | Short estate description | | Address (city + country) | ✅ | Street, postcode, region, appellation are optional | | Certifications | — | Tick any that apply to the whole estate | Save. You can reopen this any time from the **Edit vineyard** button at the top right of the admin. ### Step 2 — Add a global default Now you'll see the **Wine facts** workspace. On the **Global** tab, click **+ New** and fill in any defaults that are true of *most* of your wines — things like country of origin, bottler statement, storage advice. Save. That's enough to get a working label for every bottle in your catalogue. ### Step 3 — (Optional) Add rules for groups of wines Switch to the **Rules** tab if you want different defaults for a subset of your catalogue — for example, all your reds, all your Burgundy wines, or all your sparkling. Click **+ New**, then: 1. **Define the rule** — pick one or more facets (e.g. *Wine Style = Pinot Noir*, *Region = Burgundy*). The rule matches any product carrying all of those facet values. 2. **Fill in the fields** that should apply to that group — colour, category, typical ABV, tasting notes, etc. Save. Every matching product now picks up those values automatically. ### Step 4 — (Optional) Per-product overrides Open the admin from a specific product (the URL will include `productId=…`). The **Product** tab shows up. Override only the fields that genuinely differ for *this* bottle — typically vintage, lot number, and the precise ABV. --- ## 4. Day-to-day — what consumers see A consumer scans the bottle's QR or NFC tag. The platform routes them to the public label, passing along their language preference. The label they see, top to bottom: 1. **Header** — winery name (small caps), wine name, vintage, colour, category, alcohol %, and a PDO/PGI badge if applicable. 2. **Grape varieties** — chips with optional percentages. 3. **Tasting notes** — italicised quote. 4. **Details grid** — alcohol, net quantity, country of origin, lot number. 5. **Storage** — guidance text. 6. **Producer** — full address, producer ID, bottler statement. 7. **Certifications** — coloured chips. 8. **EU regulation footer** — the legal disclosure. Translations are picked automatically from the URL — consumers do **not** see a language switcher, by design. This keeps the label clean and EU-compliant. --- ## 5. Editing later - **Edit your vineyard** — click **Edit vineyard** at the top of the admin. - **Edit a default or rule** — open the **Global** or **Rules** tab, pick the record, change the fields, save. Updates ripple instantly to every inheriting product (unless that product has overridden the changed field). - **Edit a single product** — open the product admin and use the **Product** tab. Overrides take effect immediately. - **Reset an override** — clear the field; the inherited value takes over. - **Delete a rule** — products that matched it fall back to the global default (and any other matching rule). --- ## 6. Widgets The app ships a small family of **embeddable widgets** that surface wine info on collection home pages, search results, dashboards, or any host page. They all read the same 3-tier wine facts, so what you see in the widget always matches the full label. | Widget | What it's for | |--------|---------------| | **Wine Summary** | All-in-one teaser card with a button through to the full label. Sizes: compact, standard, large. | | **Wine Identity** | Hero card — wine name, vintage, colour, category, alcohol %. | | **Origin & Blend** | Grape varieties with percentages, PDO/PGI, country of origin. | | **Tasting Notes** | Tasting notes, serving suggestions, storage advice. | | **Producer** | Vineyard name, address, producer ID, certifications, EU regulatory facts. | | **Full Wine Facts** | Self-contained, chrome-less panel showing the **entire** EU label inline — no click-through. Use this when the host page already has its own header/branding and you just want all the legally required facts dropped in. | If a product hasn't been configured yet, widgets show a friendly empty state instead of partial data. --- ## 7. Bulk import (CSV) For large catalogues, prepare a CSV with one row per wine and import it from the SmartLinks portal's import tool. ```csv productId,wineName,vintage,wineColor,wineCategory,alcoholContent,netQuantity,netQuantityUnit,countryOfOrigin,grapeVarieties,lotNumber,bottler,storageConditions,tastingNotes prod_001,Clos du Château 2021,2021,red,still,13.5,750,ml,France,Pinot Noir 100,L2021-CN-042,Mis en bouteille au domaine,Store 12-16°C,Deep ruby with dark cherry aromas prod_002,Domaine Blanc 2022,2022,white,still,12.5,750,ml,France,Chardonnay 100,L2022-DB-019,Mis en bouteille au domaine,Serve chilled 8-10°C,Crisp citrus with mineral finish ``` | Field | Required | Accepted values | |-------|----------|-----------------| | `productId` | ✅ | Must match an existing product | | `wineName` | ✅ | | | `vintage` | — | e.g. `2021` | | `wineColor` | ✅ | `red`, `white`, `rosé` | | `wineCategory` | ✅ | `still`, `sparkling`, `semi-sparkling`, `fortified`, `liqueur`, `dealcoholized` | | `alcoholContent` | ✅ | % vol, e.g. `13.5` | | `netQuantity` | ✅ | numeric | | `netQuantityUnit` | — | `ml` (default), `cl`, `L` | | `countryOfOrigin` | ✅ | | | `grapeVarieties` | — | Comma-separated `Name Percentage` (e.g. `Pinot Noir 100`) | | `lotNumber`, `bottler`, `storageConditions`, `tastingNotes` | — | | > 💡 **Tip.** If you set up rules first and assign the right facets to each > product, your CSV only needs the fields that genuinely differ per bottle. --- ## 8. FAQ **Q: I edited a rule — do existing wines update?** A: Yes, instantly. Inheritance is resolved at view time, so every matching product picks up the change immediately on the consumer side. **Q: I deleted a rule — what happens to wines that used it?** A: Their own product-level overrides stay put. Inherited fields fall back to the next matching rule, or to the global default. **Q: A consumer sees blank fields on the label.** A: Check (1) a global default exists, (2) the product carries the facets needed for any rules you rely on, and (3) the required fields (Wine Name, Colour, Category, Alcohol, Net Quantity, Country) are set somewhere in the chain. **Q: I want one value to apply to every wine, no exceptions.** A: Set it on the **Global** default. Don't override it on rules or products. **Q: Can a product be matched by more than one rule?** A: Yes. The most specific match wins per field; product overrides always win over both rules and the global default. **Q: My CSV import skipped some rows.** A: Almost always an invalid `productId` (must match an existing product), a missing required field, or an unrecognised `wineColor` / `wineCategory` value. Fix and re-import. **Q: Why does the consumer page show a placeholder winery name?** A: You haven't saved a vineyard configuration yet. Click **Edit vineyard** and fill it in. **Q: Can I change the language switcher on the consumer label?** A: There isn't one by design — the language is set by the platform that embeds the label (usually based on the consumer's browser or scan context). This keeps the label clean and compliant. **Q: Do I need a separate label record per batch or per bottling run?** A: No. EU label info is a property of the wine, not the individual bottle — if two bottlings genuinely differ in ingredients, ABV, allergens or provenance, they're legally different products and should have their own product entries (and GTINs). --- # App module: Media Source: https://docs.smartlinks.app/setup/apps/media Learn how to manage and display images, videos, and 3D models using interactive carousels, grids, and viewers to enhance your collection or product pages. # Media App — User Guide The Media app gives your collection a single home for visual content — images, videos, 3D models, and embeds — and lets you arrange them into reusable presentations called **Media Views**. Views can be deep-linked from anywhere in your SmartLinks experience, dropped in as widgets, or used as the default the app opens to. --- ## How it works (in 30 seconds) The app has two halves: 1. **Media Pool** — your library of assets. Add anything you want to use anywhere. 2. **Media Views** — saved presentations. Each view picks items from the pool (by tag and/or type), arranges them in a chosen layout, and can be linked to or embedded. You curate the pool once. You then build as many views as you need on top of it. --- ## Getting started Open the Media admin for your collection. You'll see a header at the top and two pill tabs: **Media Pool** and **Media Views**. Use the help link in the header to jump back to these docs anytime. ### 1. Add some media In the **Media Pool** tab, click **Add Media Item**. You can bring in assets four ways: | Method | How | |---|---| | **File upload** | Browse or use the file picker | | **Drag & drop** | Drop files onto the editor | | **Paste** | Copy an image and press Ctrl/⌘+V | | **URL import / embed** | Paste an external URL — it's stored as a SmartLinks asset, or recognised as an embed (YouTube, Vimeo, etc.) | Supported types: **images** (JPG, PNG, WebP, GIF, SVG), **videos** (MP4, WebM), **3D models** (glTF, GLB), and **embeds** (YouTube, Vimeo, etc.). For each item, set: - **Title** and **alt text** — used for accessibility and admin search. - **Tags** — free-text labels (e.g. `hero`, `product-detail`, `lifestyle`). Views filter by these. - **Sort order** — controls the default order within the pool. The pool sidebar groups items by type (Images, Videos, 3D Models, Embeds) so it's easy to scan a large library. ### 2. Build a Media View Switch to the **Media Views** tab and click **Add Media View**. A view is just a name + a filter + a display mode: - **Name** — what users and pickers see (e.g. "Product Hero Carousel"). - **Tag filter / type filter** — narrow the pool down. Leave empty to include everything. - **Limit** — cap the number of items shown. - **Display mode** — Carousel, Grid, Video Player, or 3D Viewer. - **Display settings** — mode-specific options (auto-play, dots, columns, etc.). - **Appearance** — padding, sizing, scale, optional title and card chrome. - **Default view** — mark exactly one view as the app's default (see below). Save and you're done — the view is immediately available as a deep link, a widget option, and (if marked default) the app's landing screen. --- ## The default view Every Media app has **one default view** — the one shown when someone opens the app without specifying which view they want. - The first time you open the admin, a **Default Carousel** view is auto-created and flagged as default. - You can change which view is the default by editing any view and toggling its default flag. The system enforces a single default — promoting a new one automatically demotes the previous one. - If you delete the current default, the next available view is auto-promoted. This means the Media app is always usable out of the box: link to it directly, drop it on a page, or scan a proof — something sensible always renders. --- ## Display modes | Mode | Best for | |---|---| | **Carousel** | Hero banners, small image sets, swipeable galleries. Optional auto-play, arrows, dots, thumbnails, lightbox. | | **Grid / Tiles** | Product galleries, collections of equal-weight imagery. Configurable columns, gap, aspect ratio. | | **Video Player** | Demos, brand films, tutorials. Standard player controls plus auto-play, mute, loop. | | **3D Viewer** | Interactive product spins. glTF/GLB powered by Three.js with auto-rotate, zoom, pan. | All modes support **lightbox** — click any item to open it full-screen. --- ## Embedding & linking There are three ways to surface a Media View in the wider SmartLinks experience: ### As a deep link Every view is automatically registered as a deep-linkable destination. The platform's **Link Picker** lists your views by name in any other app — no IDs to copy or remember. When someone follows the link, the app opens directly on that view. Behind the scenes the URL carries a `viewId` parameter; if it's missing the app falls back to the default view. ### As a widget Place a `MediaWidget` on any page through the platform's widget system. The widget's config picker lists every view by name. If no view is selected, the widget renders the default view, so it's always safe to drop in. ### As a full-page container The Media app itself can be embedded as a container. Without parameters it shows the default view; with `?viewId=…` it filters to one specific view. --- ## Scoping Both pool items and views can live at two levels: - **Collection scope** — available across the whole collection. - **Product scope** — only available in the context of a specific product (overrides or supplements the collection-level set). Use product scope when a particular product needs its own hero carousel or its own set of lifestyle shots. --- ## Tips - **Tag intentionally.** A small, consistent tag vocabulary (e.g. `hero`, `gallery`, `detail`, `lifestyle`) lets you build many specialised views from one pool. - **Reuse the pool.** Don't duplicate an asset just to use it in a second view — add another tag instead. - **One default to rule them all.** Pick the view that makes the best first impression; that's what new visitors see. - **Keep view names clear.** They appear in pickers across the platform — `Hero Carousel` beats `View 1`. --- ## Troubleshooting **Nothing renders when I open the app.** Check the Media Views tab — you need at least one view, and one of them must be flagged as default. (One is normally seeded automatically.) **A widget is empty.** The selected view's tag/type filter probably matches no pool items. Loosen the filter or add the right tags to your assets. **3D model won't load.** Confirm it's a valid glTF or GLB file and the URL is publicly accessible. **Video won't play.** Use MP4/H.264 for widest browser support. For platform-level questions, see the [SmartLinks documentation](https://docs.smartlinks.app/setup/apps/media). --- # App module: Certification Labels Source: https://docs.smartlinks.app/setup/apps/certificationLabel Learn how to configure and display trust marks, quality certifications, and regulatory labels across your product collections to build customer confidence. # Certifications & Labels — User Guide Welcome! This guide explains how to set up, manage, and get the most out of the **Certifications & Labels** app. It's written for brand managers, marketing teams, and product owners — no technical background needed. --- ## 1. What this app does The Certifications & Labels app lets you display the certifications, quality marks, and regulatory labels that apply to your products — things like **organic**, **fair-trade**, **ISO 9001**, country-of-origin marks, or any custom badge in your brand's library. Customers see a clean, scannable list of all the certifications relevant to the product they're looking at. You stay in control of: - **Which** certifications are shown for a collection. - **Which products** each certification applies to (e.g. only organic products show the organic badge). - The **order** in which they appear. - Optional **custom values** (e.g. a control authority reference number). It's perfect for food & beverage brands, cosmetics, fashion, electronics — anywhere trust marks build buying confidence. --- ## 2. Getting started When you open the app for the first time from the SmartLinks admin console, you'll land on the label configuration screen. If you see the message *"Missing collection or app context"*, the app was opened outside the admin console — go back and launch it from your collection's apps menu. ### Your first setup in 60 seconds 1. Click **Add Label** in the top-right. 2. Search the global label library for the certifications your brand uses. 3. Click a label to add it. Repeat for each certification. 4. (Optional) For each label, choose which products it should appear on (see *Targeting products by facet* below). 5. Click **Save**. That's it — the labels are now live on the public-facing certifications page. --- ## 3. Managing your labels (the editor) The main editor is a single card titled **Certifications & Labels**, with two action buttons at the top: | Button | What it does | |---|---| | **Add Label** | Opens a searchable picker showing every certification in the global library. | | **Save / Saved** | Saves your changes. Greyed-out when there's nothing new to save. | ### Adding labels Click **Add Label** to open the picker dialog. You can: - Type to search by name. - Click any label to add it to your selection. - Already-added labels are marked so you don't add duplicates. > **Note:** The label library itself (the master list of certifications, their images, and descriptions) is managed centrally and cannot be edited from this app. If a certification you need is missing, contact your SmartLinks administrator to have it added to the global library. ### Reordering labels Each selected label has a **drag handle** (⋮⋮) on the left. Drag rows up or down to set the order in which customers see them. The first label in the list appears first on the public page. ### Removing a label Click the **✕** button on the right of any row to remove that label. ### Custom value field Some certifications (for example a control authority that requires a registration number) ask for a **custom value**. When you add such a label, an input field appears below it — fill in the required number or reference. The label on the field (e.g. "Authority Code") tells you what the certifying body expects. ### Targeting products by facet Below each label you'll see an **"Apply to products matching"** section with badges grouped by facet (e.g. **Origin**, **Category**, **Certification Type**). | What you see | What it means | |---|---| | No badges selected | Label appears on **all** products in the collection. | | One or more badges selected | Label appears **only** on products that match at least one of the selected facet values. | | *"No collection facets defined"* | Your collection has no facets set up — the label applies to all products. | **Example:** An "Organic" label with the badge `Certification Type → Organic` selected will only appear on products whose `Certification Type` facet includes `Organic`. You can pick badges across multiple facets — a product matches if **any** selected badge matches its data (OR logic). ### Saving Click **Save** when you're done. The button shows: | State | Meaning | |---|---| | **Save** (highlighted) | You have unsaved changes. | | **Saved** (with check) | Everything is up to date. | | Spinner | Currently saving. | A toast confirms success ("Label configuration updated") or alerts you to errors. --- ## 4. What customers see (public page) When a customer scans a product or visits the certifications link, they see: - A **header** showing the page title and a count (e.g. *"4 certifications"*). - A **vertical card list** — one card per certification, with: - The certification's badge image. - The certification's title. - Any custom value you entered (e.g. *"Authority Code: CERT-2024-001"*). - A short description. - A **Learn more** link to the certifying body's website (when available). Only certifications that match the product they're viewing are shown. The list respects the order you set in the editor. The page is mobile-first, fast to load, and inherits your brand's theme automatically. --- ## 5. The Labels widget This app also ships a **widget** that other SmartLinks apps and pages can embed to show a compact preview of your certifications. | Size | What it shows | |---|---| | **Compact** | Up to 4 badge thumbnails + an "Open App" button. | | **Standard** | Up to 6 badge thumbnails + an "Open App" button. | | **Large** | Up to 8 badge thumbnails + an "Open App" button. | If there are more labels than the widget can show, a **+N** indicator tells customers how many more are available. Tapping **Open App** takes them to the full certifications page. You don't configure the widget here — it automatically reflects whatever you set in the editor. Widget placement and size are chosen wherever the widget is embedded. --- ## 6. Bulk import (CSV) For large collections, you can prepare label assignments in a spreadsheet and import them. ### CSV template ```csv labelId,customValue,facetMatches label_organic,,certifications:organic label_iso9001,CERT-2024-001,certifications:iso-9001 label_fairtrade,,certifications:fair-trade;origin:france ``` ### Field reference | Column | Required? | Description | |---|---|---| | `labelId` | **Yes** | The ID of the label from the global library. Ask your administrator for the list. | | `customValue` | No | The custom reference (e.g. authority code). Only needed for labels that require one. | | `facetMatches` | No | Which products the label applies to. Format: `facetKey:valueKey`, separated by semicolons. Leave empty to apply to all products. | **Examples for `facetMatches`:** - `certifications:organic` → only products tagged with the *Organic* facet value. - `certifications:organic;origin:france` → products that are either *Organic* **or** from *France*. - *(empty)* → applies to every product in the collection. Imported rows are **merged** with your existing selection — they don't replace it. Run the import again with corrected rows to update. --- ## 7. FAQ & troubleshooting **Why don't I see my newly-added label on the public page?** Three things to check: 1. Did you click **Save** after adding it? 2. Does the label have facet badges selected that don't match the product? Try clearing all badges to confirm — the label should then appear on every product. 3. Is the product missing the facet value you targeted? Update the product's facets in your collection settings. **The "Apply to products matching" section says *"No collection facets defined"*.** Your collection hasn't been set up with facets yet. Until facets exist, every selected label is shown on every product. Ask your administrator to define facets (e.g. Certification Type, Origin) on the collection. **A certification I need isn't in the picker.** The global label library is managed centrally. Contact your SmartLinks administrator to have it added — once added, it'll appear in the picker for everyone. **Can I edit a label's image, name, or description?** No — those come from the global library and are shared across all brands. You can only choose **which** labels to display, set a **custom value**, and pick which **products** they apply to. **My label disappeared after I selected facet badges.** Selecting badges *narrows* who sees the label. If no products match the badges you picked, the label won't show anywhere. Either: - Remove some badges to broaden the audience, or - Update your products to include the matching facet values. **My CSV import skipped rows.** Common causes: missing `labelId`, the `labelId` doesn't exist in the global library, or a typo in the `facetMatches` format (must be `facetKey:valueKey`, semicolons between pairs, no spaces). **Can I change the label order after launch?** Yes, anytime. Drag rows to reorder and click **Save**. Customers see the new order on their next visit. **Can I temporarily hide a certification without removing it?** Not directly. Either remove it (you can re-add it any time) or restrict it to a facet value that no products currently have. **How do I track how many people view the certifications page?** The app automatically records a "label-view" event each time a customer opens the public page. Your SmartLinks dashboard surfaces this as the **Total Label Views** KPI. --- Need more help? Reach out to your SmartLinks administrator or contact support. --- # App module: Recipes Source: https://docs.smartlinks.app/setup/apps/recipes Learn how to create, import, and manage recipes tied to your products. This guide covers using the Recipe Builder app to publish searchable recipe portals. # Recipe Builder — User & Admin Guide A friendly guide for brand managers, marketing teams, and admins who use the Recipe Builder app to publish recipes alongside SmartLinks products. --- ## What this app does The Recipe Builder lets you publish a beautiful, searchable recipe portal connected to your products. You can: - Create recipes from scratch in a guided 5‑tab editor - **Import a recipe from any URL** — we'll extract the title, ingredients, steps, photo, and nutrition automatically - Attach recipes to **the whole collection** (every product) or to **a specific product** - Show recipes only on products that share certain tags or facets (e.g. "show this pasta recipe on every red wine") - Translate everything (UI labels and recipe content) into the customer's language automatically - Embed a featured-recipe **widget** on product pages Customers visit the public portal, browse and filter recipes, and view full recipes with ingredients, step photos, nutrition, and dietary badges. They can print or share any recipe. --- ## Getting started 1. Open the SmartLinks admin console for your collection. 2. Add the **Recipe Builder** app — you'll be asked two questions during setup: | Setting | What it does | Default | |---|---|---| | **App Title** | Display name shown on the recipe section in the parent app. | `Recipes` | | **Show Nutrition Info** | Whether nutritional info appears on recipe cards and the detail page. | `On` | 3. Once installed, open the recipe admin to start adding content. Two tabs are available at the top: - **Collection Recipes** — visible across the whole brand - **Product Recipes** — only visible on the currently-opened product (this tab is disabled if you opened the admin without a product context) --- ## Creating a recipe You have two ways to create a recipe: **manually** or **import from URL**. ### Option A — Import from URL Click **Import from URL**, paste a link to any recipe webpage, and click **Parse**. The app: 1. Scrapes the page (Firecrawl) 2. Looks for structured data (schema.org/Recipe) — fast, free, near-perfect accuracy 3. Falls back to AI extraction (Gemini) if no structured data exists A preview shows the extracted title, image, ingredients, steps, nutrition, and tags. Click **Import Recipe** to save it as a draft you can edit further. > 💡 Tip: imported recipes always start as **drafts** — review them before publishing. ### Option B — Create manually Click **Add Recipe** to open the editor. The editor has 5 tabs: #### 1. Basics | Field | Required | Notes | |---|---|---| | Recipe Name | ✅ | Title shown everywhere. | | Author | – | E.g. "Chef Maria". | | Description | – | One or two sentences shown on cards and detail. | | Recipe Image | – | Use the asset picker to upload, browse, or import by URL. | | Category | ✅ | Choose from: Appetizer, Starter, Main Course, Side Dish, Dessert, Drink, Cocktail, Snack, Breakfast, Brunch, Lunch, Dinner, Other. | | Cuisine | – | Free text (e.g. "Italian", "Thai"). | | Difficulty | – | Easy, Medium, or Hard. | | Keywords | – | Comma-separated, used in search (e.g. `quick, summer, vegetarian`). | | Prep Time | – | Minutes. | | Cook Time | – | Minutes. Total time is auto-calculated. | | Yield | – | Free text (e.g. "12 cookies"). | | Servings | – | Numeric, used in nutrition labels. | | Dietary Information | – | Toggle any of: Vegetarian, Vegan, Gluten-Free, Dairy-Free, Nut-Free, Low-Carb, Keto, Paleo, Halal, Kosher. | #### 2. Ingredients Click **Add Ingredient** for each line. Each ingredient has an amount, unit, name, and optional notes (e.g. "finely chopped"). Re-order is coming soon. > ⚠️ A recipe must have **at least one ingredient with a name** to save. #### 3. Instructions Click **Add Step** to add a step. Each step has: - A step number (auto-managed) - The instruction text - An optional **step image** (asset picker) - An optional **tip** (shown as a callout under the step) > ⚠️ A recipe must have **at least one instruction step with text** to save. #### 4. Nutrition Per-serving values for Calories, Protein (g), Carbs (g), and Fat (g). All optional. Hidden on the public site if "Show Nutrition Info" is off in app settings. #### 5. Settings | Setting | What it does | |---|---| | **Published** | Off = draft (only you see it). On = visible to customers. | | **Featured** | Pinned to the top of lists and used by the widget. | | **Show this recipe on…** *(collection recipes only)* | Choose: **All products in collection**, **Products matching facets**, or **Specific products** (coming soon). | | **Tags** | Free-form lowercase tags. Recipes can be matched to products that share the same tags. | | **Source URL** | If you adapted this from somewhere else, link it here — shown as a button on the public detail page. | ### "Show this recipe on…" explained For collection recipes, you decide which products in the collection display the recipe: - **All products in collection** — the recipe appears on every product. Use for general brand recipes. - **Products matching facets** — pick one or more facet values (e.g. *Pairs with → Beef*, *Region → Tuscany*). The recipe shows on any product that has at least one matching value. Requires facets defined on your collection. - **Specific products** — pick exact products to attach to (rolling out soon). --- ## What customers see (day-to-day usage) When a customer scans a SmartLinks code or opens a product page that includes the Recipe Builder, they see: 1. **A recipe portal** with all published recipes for their context 2. A **search bar** (matches title, description, cuisine, ingredients, keywords, tags) 3. A **filter panel** with Category and Dietary filters (only shows filters that have results) 4. **Recipe cards** with image, title, time, servings, difficulty, and a few badges 5. Tap a card → full **recipe detail** page with: - Hero image - Prep / Cook / Total time and servings - **Print** and **Share** buttons (Share uses native OS share if available) - **Source** button if a Source URL was provided - Ingredients list (with amounts and notes) - Numbered, illustrated step-by-step instructions - Nutrition panel (if enabled) - Dietary badges and keywords Recipe content is **auto-translated** into the customer's language (currently English, German, French) — names, descriptions, ingredients, and steps included. Translations are cached, so subsequent loads are instant. --- ## Widgets The Recipe Builder ships with one embeddable widget, **RecipeWidget**, which shows the featured recipe on a product page. | Size | What it shows | |---|---| | **Compact** | Minimal card with title and a button to open the recipe portal. | | **Standard** | Card with hero image, title, description, time, servings. | | **Large** | Full featured card — adds "+N more recipes" hint when you have several. | Pick the size in the parent app's widget settings. The widget always shows your **Featured** recipe first; if none is featured, it shows the most recently updated one. --- ## Managing recipes From the recipe list (Collection or Product tab): - **Search** by name, description, cuisine, or keyword - Hover any card to see quick actions: - **Eye icon** — publish/unpublish without opening the editor - **Pencil icon** — open the full editor - **Trash icon** — delete (with confirmation) - A status strip at the top shows **total recipes**, **how many are published**, and the current scope (collection-wide or product-specific) - Featured recipes always sort to the top, then alphabetical > 💡 Switching the **Published** toggle off temporarily hides a recipe from customers without losing any data — perfect for seasonal items. --- ## FAQ & troubleshooting **Why can't I see the Product Recipes tab?** The Product Recipes tab only activates when you opened the admin from a specific product. If you came in from collection-level admin, switch to a product first. **My import failed — what now?** The import tool tries structured data first (works on most major recipe sites), then falls back to AI. If both fail, the page may be JavaScript-rendered, blocked, or have no recognisable recipe content. Try a different source, or click **Add Recipe** to enter it manually. **Can I edit an imported recipe?** Yes — imported recipes save as drafts. Open them in the editor and tweak anything before publishing. **A recipe shows the wrong category emoji.** Each category has a fixed emoji (🥗 Appetizer, 🍝 Dinner, etc.). Change the **Category** in the Basics tab to update it. **Customers see English text even though their browser is in French.** The portal language is driven by the `?lang=fr` URL parameter set by the SmartLinks platform. Make sure the parent app passes the customer's language. Static labels are pre-translated; recipe content is auto-translated on first view and then cached. **I changed a recipe but customers still see the old version.** Translations are cached locally for performance. Customers will pick up updates within a few minutes, or immediately on a hard refresh. **Can I show a recipe on only certain products?** Yes — open the recipe, go to **Settings → Show this recipe on…**, choose **Products matching facets**, and tick the values that should match. The recipe appears on any product that has at least one of those values. **A recipe won't save — the Save button is greyed out.** You need three things: a **Recipe Name**, **at least one ingredient**, and **at least one instruction step**. **Where do uploaded photos live?** All recipe and step images go through the SmartLinks asset library. You can re-use any previously uploaded asset, upload a new one, or import from a URL — all from the same dialog inside the editor. **Does deleting a recipe remove its images?** No — images stay in the asset library so other recipes can use them. Remove them from the asset library directly if you want them gone. --- ## Future surfaces This app does not currently ship a MobileAdminContainer. If a field/operator workflow is added later (NFC tap, QR scan, in-the-field check-in, on-device camera capture by an admin), follow migration step 12. --- # App module: Ingredients Source: https://docs.smartlinks.app/setup/apps/ingredients Learn how to manage ingredient lists and allergen warnings for your products using the Ingredients & Allergens app, including AI-powered parsing and dietary labels. # Ingredients & Allergens — Admin User Guide A walkthrough of the admin UX for the Ingredients & Allergens app. --- ## 1. What this app does The Ingredients & Allergens app puts a clear, trustworthy ingredient label behind every product scan. When a customer scans a product (or opens its page) they see: - A readable ingredient list, automatically translated into their language - The 14 EU major allergens that are present, with clear icons - "May contain" warnings for cross-contamination risk - Dietary labels (vegan, halal, gluten-free, organic, and more) - A personal warning if the product contains anything on **their** allergen list It works for food, drink, cosmetics, and any product where ingredient transparency matters. You configure ingredients once per product (or once for a group of products); customers get a personalised, multilingual experience automatically. --- ## 2. The admin shell at a glance When you open the app from a product page in the SmartLinks console, you land directly on that product's ingredient record — no picker, no list view. The shell has three parts: - **Scope tabs along the top** — `Collection · Rule · Facet · Product · Variant · Batch`. The tab matching the current context is selected by default. Switching tabs lets you target a different anchor (e.g. share a recipe across a whole facet, or override one batch). - **Browser pane on the left** — lists every existing record in the current scope. Click one to load it into the editor. Use the **+ New** button to start a fresh record. - **Editor on the right** — the five editing tabs (Ingredients, Allergens, Dietary, Origin, Translations). A **sticky footer** runs along the bottom with **Save**, **Discard**, and **Delete**. The Save button is enabled only when there are unsaved changes; Delete is hidden until a record has been saved at least once. **Inheritance markers.** When you view a product that doesn't have its own record but inherits one from a facet rule, the editor shows a small banner indicating where the record actually lives. Edits create a new product-level record that overrides the inherited one. --- ## 3. Creating a record ### Step 1 — Pick the scope Use the scope tab that matches what you want to do: | Tab | When to use it | |---|---| | **Product** | This product has a unique recipe. The default when you open from a product page. | | **Variant / Batch** | Override the product-level recipe for a specific variant (e.g. 500ml vs 1L) or a single batch. | | **Facet** | Anchor the recipe to one facet value (e.g. "Red Wines"). Every product tagged with that value picks it up. | | **Rule** | Match products dynamically by a combination of facets (e.g. brand = Acme AND category = sourdough). | | **Collection** | A catch-all for the whole brand. The lowest-priority fallback. | **Override order (most specific wins):** batch → variant → product → rule → facet → collection. ### Step 2 — Fill in the editor #### Ingredients tab Paste your full ingredient text — comma-separated, exactly as it appears on the pack. Click **Parse with AI**. The parser splits ingredients, flags allergens, suggests dietary labels, and seeds translations. Each detected ingredient appears as a badge; ones containing an allergen are highlighted. #### Allergens tab Two sections, both showing all 14 EU major allergens: | Section | Meaning | |---|---| | **Contains (confirmed)** | Allergens intentionally in the recipe. | | **May Contain (traces)** | Allergens that *might* be present from cross-contamination or shared lines. | Click an allergen to toggle it. Each allergen can only sit in one list at a time — moving it to "Contains" automatically removes it from "May Contain", and vice versa. The 14 EU major allergens: celery, gluten-containing cereals, crustaceans, eggs, fish, lupin, milk, molluscs, mustard, tree nuts, peanuts, sesame, soybeans, sulphites. #### Dietary tab Toggle each label that applies (vegan, vegetarian, pescatarian, halal, kosher, gluten-free, dairy-free, nut-free, low-sodium, organic). For every label you turn on, choose how it should be shown: | Visibility | What customers see | |---|---| | **Prominent** | Shown to **everyone** — front-of-pack style. Use for badges you're proud of (Organic, Vegan). | | **Discrete** | Hidden by default. Only shown to customers who have told the app they care about that need (e.g. Kosher). | | **Off** | The label is recorded but not displayed. | #### Origin tab For each parsed ingredient, optionally add a country or region (e.g. "France", "Italy"). Customers see this in the detailed ingredient view. Leave blank if unknown. #### Translations tab Translations now work in two layers: 1. **On-the-fly** — at render time the public page asks the platform's translation service to translate any missing language on demand, using the source-language `rawIngredientText`. Customers in all 10 supported languages get something readable without you doing anything. Machine-translated entries are tagged **"auto"** on the public page, with a small disclaimer that the printed pack is authoritative. 2. **Curated overrides** — click any language button to generate, review, and save a translation into the record. Saved translations are treated as authoritative and replace the machine version. A green tick appears next to languages that have a curated entry. Use the curated layer when copy matters (regulated markets, legal phrasing, brand voice); otherwise the on-the-fly layer is enough. ### Step 3 — Save Click **Save** in the sticky footer. The record is written immediately and the public page picks it up within seconds. --- ## 4. Day-to-day usage — what customers see A customer scans a product or opens its page and lands on the ingredients view: 1. **Language.** Loads in the customer's preferred language. Curated translations win; otherwise the platform translates on the fly. 2. **Ingredient list.** Clean, readable, comma-formatted. Allergen keywords are bolded. 3. **Allergen banner.** A red warning appears at the top if the product contains any of the customer's personal allergens. 4. **"Contains" allergens.** Red badges with icons. 5. **"May contain" warnings.** Amber badges with icons. 6. **Dietary labels.** Prominent labels shown to everyone; discrete labels only appear if the customer has selected that need. 7. **Settings panel.** A gear icon lets the customer set their own allergens, dietary needs, and preferred language. Their choices are remembered on their device. 8. **Printable label.** A print button generates a clean, regulation-style label. Everything is mobile-first and needs no login. --- ## 5. Widgets The app ships one widget for embedding inside other SmartLinks apps and pages: **Ingredients Widget**, in three sizes: | Size | Shows | |---|---| | **Compact** | Allergen icons only, plus a "View Full Details" button. | | **Standard** *(default)* | Allergen icons + prominent dietary labels + button. | | **Large** | Everything in Standard plus the total ingredient count. | If the customer has set personal allergens, the widget highlights the ones that affect them. --- ## 6. Managing existing records The browser pane on the left lists every record in the current scope. Each row shows the anchor (product name, facet value, rule summary) and a quick summary of allergens. - **To edit:** click the row. The editor loads its contents. - **To delete:** open the record and click **Delete** in the sticky footer. You'll be asked to confirm. - **To override an inherited record:** open the product, make your changes, and Save — a product-level record is created that takes precedence over the facet/rule it was inheriting from. **Tip:** Because facet and rule records cover many products at once, deleting one affects every product they match. Edit instead when in doubt. --- ## 7. Bulk import Bulk import is handled via the SmartLinks console's record importer using the records API. The shape of each row matches the `data` payload documented in `public/ai-guide.md` (§3). Use that document — or hand it to your AI tooling — when preparing large catalogues. --- ## 8. FAQ & troubleshooting **Q: I created a facet record but a few products still show no ingredients.** Check those products are actually tagged with the facet value you used. Untagged products won't match. Either tag them, or create a product-level record for the exception. **Q: I edited a record but the product page still shows the old ingredients.** The customer's browser may have cached the page. Ask them to refresh. Changes are live within seconds for new visitors. **Q: AI parsing missed an allergen — is it safe to rely on?** Always review what the AI detected before saving. The Allergens tab is your final say. Toggle anything that's wrong. The app stores **your** confirmed list, not the AI's guess. **Q: A customer says they're allergic to something but the warning didn't appear.** Personal warnings only fire for allergens the customer has added to their own preferences (via the gear icon on the public page). Only the 14 EU majors are tracked. **Q: Can I change the list after launch?** Yes — edit anytime. There's no review flow; saves go live immediately. Be careful with allergen edits, since these have legal implications. **Q: A product is part of a facet group but needs a different recipe for one batch.** Create a batch-level (or variant-level) record. It overrides the shared facet record without affecting the other products in the group. **Q: Translations look slightly wrong in one language.** Open the Translations tab, regenerate the language, edit the text inline, and Save — your curated version replaces the on-the-fly machine translation. **Q: What happens if I delete a facet record that covers 50 products?** All 50 products lose their ingredient page until a replacement record matches them. The public UI shows "Allergen information unavailable" rather than displaying anything incorrect. --- *This guide reflects the live behaviour of the app. If something on screen doesn't match what you read here, the on-screen behaviour wins — please flag it so we can update this document.* --- ## Future surfaces This app does not currently ship a MobileAdminContainer. Admin configuration is desk-based and there is no field/operator workflow requiring NFC, RFID, QR scanning, or camera capture by admins. --- # App module: Nutrition Source: https://docs.smartlinks.app/setup/apps/nutrition Learn how to manage and display compliant nutrition labels across your product catalog using the US FDA, EU FIC, and UK Traffic Light formats. # Nutrition Facts — User & Admin Guide A practical guide for brand managers and marketing teams using the Nutrition Facts app. --- ## 1. What this app does Nutrition Facts is a small app that lets your brand show **clear, compliant nutrition information** for every product in your catalogue — and lets your team manage that information without spreadsheets or developers. It supports three label formats out of the box: | Format | Where it's used | |--------|-----------------| | **US FDA** | United States — the classic black-and-white "Nutrition Facts" panel | | **EU FIC** | European Union — the per-100g/ml table required by EU 1169/2011 | | **UK Traffic Light** | United Kingdom — the colour-coded front-of-pack labels | It's designed for: - **Food & beverage brands** that need to publish nutrition data per product - **Marketing teams** who want to show consumers what's in a product without involving engineering - **Operations teams** managing multiple SKUs, recipe families, or product lines You can enter data **per product** (one product, one set of values) or as a **rule** that applies to many products at once (e.g. all "Wholewheat Bread" products share the same panel) — and you can scan a physical label with your phone camera to fill the form automatically. --- ## 2. Getting started ### Opening the admin The app is opened from inside the SmartLinks Admin Console: 1. Go to your collection in SmartLinks 2. Open the **Nutrition Facts** app 3. Pick a product, or open it at the collection level to manage rules that span many products If you open the app without a collection selected, you'll see a friendly reminder asking you to open it from the Admin Console with a collection (and optionally a product). ### First-time collection setup When you install the app for the first time on a collection, you'll be asked four setup questions: | Question | What to choose | |----------|----------------| | **Default label format** | The format shown to customers by default. Pick **Auto** to let it follow each visitor's location (US visitors see FDA, UK visitors see Traffic Light, everyone else sees EU FIC), or lock it to one format for your main market. | | **Available label formats** | Tick every format you want to be selectable. Most brands enable all three. | | **Display validated health claims?** | On if you want to show approved claims like "Low fat" or "Source of fibre" on the public label. | | **Food or beverage?** | Affects whether values are shown per **100 g** (food) or **100 ml** (beverage), and how traffic lights are calculated. | You can change all of these later — they're not locked in. --- ## 3. Entering nutrition data When you open the app you'll see a **records manager** with three parts: - **Left rail** — a browser listing the records you've created, with a scope picker at the top. - **Editor (middle)** — the nutrition form for whatever you've selected. - **Live preview (right)** — the customer-facing label, updating as you type. A scope picker lets you preview at product, variant, or batch level. ### Choosing a scope The scope picker at the top of the left rail decides *what* a set of values applies to: | Scope | Use it for | |-------|------------| | **Rule** | Values shared by many products at once, matched by their facets (e.g. *category = bread AND gluten-free = true*). Reach for this first if products share a recipe. | | **Product** | Values for one specific product. Always wins over any rule that also matches it. | | **Variant** | Values for a specific variant of a product (e.g. the 500 ml size), when variants are enabled on the collection. | | **Batch** | Values for a specific production batch, when batches are enabled. | > **Rule of precedence:** the most specific record wins. A batch beats a variant, a variant beats a product, and a product always beats a rule. A rule only applies when nothing more specific exists. The Variant and Batch scopes only appear when those features are switched on for the collection. ### Creating and editing a record 1. Pick a scope (Rule, Product, Variant, or Batch) in the left rail. 2. Select an existing entry, or create a new one (for **Rule**, you also define the facet conditions that decide which products it covers). 3. Fill in the form in the middle. The preview on the right updates live. 4. **Save** or **Discard** using the buttons in the footer at the bottom of the editor. If a product is currently getting its values from a less-specific record (e.g. a rule), you'll see a notice at the top of the editor telling you it's **inherited**. You can leave it as-is, or click **Start from parent values** to copy those numbers in and override them just for the current scope. ### The nutrition form The form is split into three sub-tabs: #### Nutrients tab All values are entered **per 100 g** (food) or **per 100 ml** (beverage). Leave a field blank if you don't have the data — it just won't appear on the label. | Field | Unit | Notes | |-------|------|-------| | Energy | kJ and kcal | EU labels need both; US needs kcal only | | Fat | g | Required for traffic lights | | Saturated Fat | g | Required for traffic lights | | Mono- / Poly-unsaturated Fat | g | Optional, EU only | | Trans Fat | g | Required on US labels | | Carbohydrate | g | | | Sugars | g | Required for traffic lights | | Added Sugars | g | US-required, useful elsewhere | | Polyols, Starch | g | Optional EU detail | | Fibre | g | | | Protein | g | | | Salt | g | Required for traffic lights (UK uses salt, US uses sodium) | | Cholesterol | mg | US-required | #### Serving tab | Field | What it does | |-------|--------------| | **Product Type** | Food or Beverage. Switches the per-100 unit between g and ml, and adjusts traffic light thresholds. | | **Serving Size** | A number plus unit (g or ml). Used to calculate per-serving values shown on the label. | | **Serving Description** | Free text shown to customers, e.g. "1 slice (30 g)" or "1 can (330 ml)". | | **Servings per Container** | Optional. Required on US FDA labels. | #### Health Claims tab Add any approved nutrition or health claims for the product (e.g. "Low fat", "Source of fibre", "High in protein"). For each claim: - **Claim text** — what appears on the label - **Regulation** — which framework approved it: EU, UK, or US - **Valid** — toggle on once you've confirmed the claim meets the regulation. Only validated claims are shown to customers when "Display validated health claims" is on. - **Trash icon** — remove the claim ### Filling the form with AI label scanning Instead of typing values, click **AI Label Scan** at the top of the form. A dialog opens where you can: 1. Take a photo with your camera (preferred on phone/tablet) 2. Or upload a photo of an existing nutrition label The AI reads the label, extracts the values, and fills the form for whichever record you're currently editing. Review the values, edit if needed, then **Save** in the footer. ### Saving Use the **Save** / **Discard** buttons in the footer at the bottom of the editor. If you try to switch records with unsaved changes, the app prompts you so you don't lose work. --- ## 4. What customers see When a customer opens a product page that has nutrition data, they see: 1. The **product name** at the top 2. A **nutrition label** in your collection's default format (US FDA, EU FIC, or UK Traffic Light) 3. **Validated health claims** below the label (if you enabled this in setup) If the customer's link allows format switching (configurable in your collection settings), they can toggle between formats. If a product has no per-product data and no matching rule, the customer sees a friendly "Nutrition information has not been configured for this product yet" message instead of an empty label. --- ## 5. Widgets The app ships **one** embeddable widget you can drop into other SmartLinks surfaces (product cards, landing pages, etc.): ### Nutrition Widget A compact card showing the most important info at a glance: - **Calories per serving** — large number at the top - **Traffic-light pills** for Fat, Saturates, Sugars, and Salt (green / amber / red) - A **View full label** button that opens the full app Available in three sizes: | Size | When to use | |------|-------------| | **compact** | Tight spaces — sidebars, list rows. Smaller text and pills. | | **standard** | Default. Full card with all the highlights. | | **large** | Hero placement — larger numbers and more padding. | The widget pulls data automatically using the same rules as the admin: the most specific record wins (product over rule). --- ## 6. Managing your records ### Previewing The live preview pane on the right of the editor always shows exactly what the customer will see for the currently selected scope. The scope picker above the preview lets you check product, variant, or batch level. A badge tells you whether the data is **set here** or **inherited** from a less-specific record. ### Editing Select the record in the left rail, change the values, and **Save** in the footer. There's no separate "edit" mode — the form is always editable. ### Removing nutrition from a record Use the **Delete** action on a record to remove it. Once a product's own record is deleted, it falls back to any rule that matches it — or shows no nutrition if nothing else applies. ### Switching display settings later The defaults you picked during setup (default format, enabled formats, health claims toggle, food vs beverage) can be changed at any time. The quickest way to change the **default label format** is the label-format popover in the top-right of the admin header — pick **Auto** or a specific format and it saves for the whole collection. The other display settings are runtime/tunable settings in the SmartLinks Admin Console and don't require re-doing setup. --- ## 7. Bulk import (CSV) For brands with many products, you can import nutrition for multiple products at once instead of entering them one by one. This is the fastest way to load data from a PIM or ERP export. ### CSV template ```csv productId,energyKcal,energyKj,fat,saturatedFat,carbohydrate,sugars,fibre,protein,salt,servingSize,servingSizeUnit,productType prod_001,250,1046,12,3.5,30,8,2.5,8,1.2,30,g,food prod_002,180,753,4.2,1.1,28,6,3.0,7,0.8,40,g,food prod_003,42,176,0,0,10.5,10.5,0,0,0.01,330,ml,beverage ``` ### Field reference | Column | Required | Type | Notes | |--------|----------|------|-------| | `productId` | Yes | text | The SmartLinks product ID (must already exist in the collection) | | `energyKcal` | Yes | number | Energy in kcal per 100 g/ml | | `energyKj` | No | number | Energy in kJ per 100 g/ml — recommended for EU labels | | `fat` | Yes | number | Total fat, g per 100 g/ml | | `saturatedFat` | Yes | number | Saturated fat, g per 100 g/ml | | `carbohydrate` | Yes | number | g per 100 g/ml | | `sugars` | Yes | number | g per 100 g/ml | | `fibre` | Yes | number | g per 100 g/ml | | `protein` | Yes | number | g per 100 g/ml | | `salt` | Yes | number | g per 100 g/ml | | `servingSize` | No | number | Defaults to 100 | | `servingSizeUnit` | No | text | `g` or `ml`. Defaults to `g`. | | `productType` | No | text | `food` or `beverage`. Defaults to `food`. | ### Tips - Save the file as **UTF-8 CSV**. - Make sure `productId` exactly matches the IDs in your collection — typos are the #1 cause of skipped rows. - For beverages, set `servingSizeUnit` to `ml` so labels show per-100 ml. - Imports overwrite existing per-product data for the same product — no merge. --- ## 8. FAQ & troubleshooting **Why doesn't anything show up for my product?** There's no product record saved and no rule that matches it. Open the product in admin and either fill in the form on the **Product** scope, or create a **Rule** whose facet conditions match it. **My product is showing values I didn't enter.** It's inheriting from a less-specific record — usually a rule. Look at the **inherited** notice at the top of the editor — it tells you the values are coming from a parent scope. To override, click **Start from parent values** (or just type new ones) and save on the Product scope. **I changed a rule's values but one product still shows the old numbers.** That product probably has its own product record, which always wins over a rule. Delete the product record if you want it to inherit from the rule again. **The AI scan got the numbers wrong.** Always review the values after a scan. Lighting, glare, and rotated photos reduce accuracy. Re-take the photo in good light, or just edit the wrong fields by hand and save. **Traffic light colours look wrong.** Traffic lights use UK FSA thresholds and depend on **Product Type**. Check the Serving tab — if you've set a beverage as "food" (or vice versa), thresholds will be off. Switch the Product Type and save. **My CSV import skipped some rows.** Most common causes: - A `productId` doesn't exist in this collection - A required column (energy, fat, sugars, salt etc.) is blank or non-numeric - The file isn't saved as UTF-8 CSV Re-export and try again — the importer will tell you which rows failed. **Can I change the default label format after going live?** Yes. It's a runtime setting — change it any time from the label-format popover in the top-right of the admin header (or the SmartLinks Admin Console) without re-running setup. Choose **Auto** to follow each visitor's locale. **Can I have a product show in US FDA format while another shows in EU FIC?** The collection has one default format. Customers can switch formats themselves if you've enabled multiple formats in setup. **What happens to health claims I haven't validated?** They're saved with your record but **not shown to customers** until you toggle them as "Valid". This stops draft or unverified claims from accidentally going public. ## Future surfaces This app does not currently ship a MobileAdminContainer. If a field/operator workflow (NFC/RFID/QR scanning, in-the-field check-in/authorisation) is added later, follow migration step 12. --- # App module: FAQ App Source: https://docs.smartlinks.app/setup/apps/faq Manage and display searchable frequently asked questions for your collections and products using a rich text editor, product-specific tagging, and bulk CSV imports. # FAQ App — User Guide ## What this app does The FAQ app lets you publish frequently asked questions for your products. Shoppers see a clean, searchable FAQ on your product pages; you manage the questions and answers from a simple admin screen. You can write FAQs that apply to your **whole collection**, to a single **product, variant, or batch**, or to anything matching a custom **rule**. --- ## Getting started 1. Pick a **scope tab** along the top to choose where the FAQ should appear (see [Choosing where an FAQ appears](#choosing-where-an-faq-appears-scopes)). 2. Click **+ New FAQ**, type a question, and write the answer. 3. Click **Save** — shoppers see the new FAQ right away on the matching pages. There's nothing to configure up front. --- ## Choosing where an FAQ appears (scopes) Use the tabs at the top of the admin to choose where an FAQ shows up: | Tab | Where the FAQ appears | |--------------|--------------------------------------------------------------------| | **Collection** | On every product — your "global" FAQs. | | **Product** | Only on one specific product. | | **Variant** | Only on one specific variant of a product. | | **Batch** | Only on one specific batch. | | **Rule** | On any product matching a rule you build from product properties. | More-specific FAQs appear **alongside** the broader ones. For example, a Product FAQ shows up together with your Collection-wide FAQs on that product — it doesn't replace them. ### Building a Rule Use Rules when you want an FAQ to appear on a *group* of products without having to add it to each one. For example: "show this on all wines sold in the EU". 1. Open the **Rule** tab and click **+ New FAQ**. 2. Above the question/answer you'll see a **Facet rule** panel. 3. Add one or more **groups**. Inside a group, pick a property (e.g. *category*) and the values you want to match (e.g. *wine*, *spirits*). A product matches the group when it has at least one of those values for every property the group lists. 4. If you add more than one group, a product matches the rule when it matches **any** group. Use the **preview** to test your rule against a sample product before saving. --- ## Day-to-day ### What shoppers see - A searchable list of questions on the FAQ page. - Click a question to expand the answer. - Answers display with proper formatting — headings, lists, links, quotes, etc. ### What you do as an admin - Pick a scope tab, then **add**, **edit**, **duplicate** or **remove** FAQs. - Toggle the **side preview** in the editor to see exactly how the FAQ will look before you save. - Use the **scope picker** in the preview to check how a Rule or Product FAQ looks on a specific product. ### Saving - **Save** on a new FAQ creates it. - **Save** on an existing FAQ updates it. - If you switch tabs or pick another FAQ with unsaved changes, you'll be warned first. ### Deleting The delete icon removes an FAQ **immediately** — there's no confirmation, so take a moment before clicking. If a deleted FAQ still appears in the list, just refresh the page. --- ## Writing answers The answer field is a simple rich-text editor. Use the toolbar for: - **Bold**, *italic*, ~~strikethrough~~, underline, inline `code` - Headings - Bulleted and numbered lists - Block quotes and dividers - Links (a small popover lets you enter or change the URL) - Undo / redo Keep answers short and clear — most shoppers skim. Lead with the answer, then add detail underneath if needed. --- ## Tagging FAQs Each FAQ has an optional **Tags** field (comma-separated, e.g. `shipping, returns`). Tags are used by the "Group by tag" option in **Customize view** to organise the public page into tabs. Untagged FAQs fall under an "Other" tab when grouping is on. --- ## Customizing the public view Open **Customize view** from the admin header to change how shoppers see your FAQ page. Changes are saved per collection and apply to every product. A live preview (using your real FAQs) shows changes as you make them. ### Layout - **Accordion** (default) — questions collapse, click to expand one answer at a time. Best for long lists. - **Open list** — every question shows its answer underneath. Best for short lists you want shoppers to skim. ### Grouping - **Group by tag** — splits the FAQs into tabs by their tag. Untagged FAQs go under "Other". Turn this on once you've tagged enough FAQs to make the tabs useful. ### Display options - **Show search bar** — adds a live filter above the list. - **Expand all by default** (accordion only) — opens every answer on load. - **Custom heading + intro** — replace the default "FAQ" title and intro paragraph with your own copy. - **Footer CTA** — a button under the FAQ that links anywhere: another app, a deep link, or an external URL. Use it for "Still have questions? Contact us". --- ## Where FAQs appear publicly ### The FAQ page A full searchable list of every FAQ that applies to the current product (or your collection-wide FAQs when there's no specific product in context). ### The FAQ widget A compact preview you can drop into a product page or dashboard. Three sizes are available: | Size | Questions shown | Best for | |------------|-----------------|---------------------------| | Compact | 1 | Sidebars, tight spaces | | Standard | 2 | Product pages (default) | | Large | 3 | Dedicated FAQ sections | Clicking a question expands it inline; **View all** opens the full FAQ page. --- ## Languages If your audience uses multiple languages, FAQs are translated automatically when a language is selected. You only need to write your FAQs once (in English) — translations happen on the fly. If a translation can't be loaded for any reason, shoppers see the original English text rather than an error. --- ## Troubleshooting **The admin says "Missing required URL parameters"** Your link is missing the collection ID or app ID. Use the link your team provided, or ask them to resend it. **My new FAQ isn't showing up on a product** Check the scope. Collection FAQs show everywhere; Product/Variant/Batch FAQs only on that exact item; Rule FAQs only where the rule matches. Use the editor's preview (with the scope picker) to test. **I deleted an FAQ but it's still in the list** Refresh the page — the FAQ is already gone on the server. **The widget says "No FAQs available"** There are no FAQs that apply to that product yet. Add a Collection-wide FAQ, or one targeted to that product/rule. --- ## Tips for great FAQs - **Answer the question in the first sentence.** Add detail below. - **Use the customer's words**, not internal jargon. - **One question per FAQ.** If an answer covers two things, split it. - **Cover the basics first** — shipping, returns, authenticity, care — then add the niche stuff. - **Review periodically.** Remove FAQs that are no longer accurate; promote product-specific FAQs to the collection if everyone's asking. --- # App module: Certificate Designer Source: https://docs.smartlinks.app/setup/apps/certificate Learn how to use the Certificate Designer to create and automate dynamic, branded Certificates of Authenticity using a visual editor, AI tools, and Liquid data. # Certificate Designer — User Guide ## What this app does The **Certificate Designer** turns every product proof into a beautifully designed **Certificate of Authenticity** (or ownership, qualification, provenance — whatever fits your brand). You design the certificate once, in the visual designer. Each customer who scans a SmartLinks proof then sees a personalised, full-page certificate with their unique serial number, owner name, and any other data you choose to include. They can download it as a PDF or print it. --- ## Getting Started 1. **Open the admin panel** for your collection. 2. **Pick a starting template** — Classic, Modern, Vintage, Minimalist or Royal. 3. **Tweak it** in any of three ways: - **Templates panel** (left) — pick a different preset. - **Design controls** (right) — fine-tune copy, colours, fonts, border, seal. - **AI designer** (bottom-left) — type what you want in plain English: _"make it more royal"_, _"add gold accents"_, _"change the title to Diploma of Mastery"_. 4. **Click Save**. Your design is now live for every proof in this collection. --- ## The three panels ### Left — Templates + AI - **Templates** — Click any preset to swap the entire design. Confirm before applying (it overwrites your current choices). - **AI designer** — Conversational helper. Describe a feel ("make it darker"), a specific change ("change the signature line to 'Master Distiller'"), or an evolution ("more elegant"). The AI updates the design directly. ### Centre — Live preview A scaled live preview of your certificate using sample proof data. What you see is what customers will see (with their own data substituted in). ### Right — Detailed controls #### Design tab | Section | What it controls | |---|---| | **Content & copy** | All text on the certificate (title, subtitle, body, signature, footer). Liquid placeholders supported. | | **Data fields** | The grid of dynamic fields (Serial, Issued On, etc.). Add, remove, or change which proof data they pull from. | | **Typography** | Display and body fonts. Curated Google Fonts list. | | **Colours** | Background, text, accent and muted colours. HSL strings. | | **Layout & border** | Orientation (landscape/portrait), border style (none/single/double/ornate/rounded), border width. | | **Seal** | Toggle the round seal, customise its text, optionally add an image. | #### Facets tab Per-facet overrides. If your products have facets like `tier=gold` or `category=premium`, you can override specific design fields just for those products. For example, gold-tier products can use a gold accent colour while everything else stays default. --- ## Liquid placeholders Anywhere you see a text field, you can use Liquid syntax to pull in live data: | Placeholder | Example output | |---|---| | `{{ collection.name }}` | Acme Distillery | | `{{ product.name }}` | Reserve Single Malt 2023 | | `{{ proof.id }}` | prf_8a3f29 | | `{{ proof.serialNumber }}` | 00142 / 02500 | | `{{ proof.owner }}` | Alice Chen | | `{{ proof.createdAt }}` | 2024-09-14 | | `{{ attestation. }}` | Any attestation field on the proof | | `{{ now }}` | Today's date | **Defaults:** Use `{{ proof.owner | default: "—" }}` to show a fallback when the field is empty. --- ## Day-to-day usage Once configured, every customer who lands on a SmartLinks proof page sees the certificate. They can: - **View** it in the browser (auto-fitted to their screen). - **Download** it as a PDF (Download PDF button). - **Print** it directly (Print button uses native browser print with optimised margins). No further admin action is needed per certificate — they're generated on the fly. --- ## FAQ & Troubleshooting **Q: I changed the design but customers still see the old one.** A: Did you click **Save**? Unsaved changes are noted in the top bar ("· unsaved changes"). After saving, customers see the new design immediately on next page load. **Q: A field says "—" or shows the raw `{{ ... }}` text.** A: The proof doesn't have data for that field. Either pick a different field, use the `default` Liquid filter (e.g. `{{ proof.owner | default: "Anonymous" }}`), or check that the data was set when the proof was created. **Q: The PDF download looks pixelated.** A: This usually means the certificate has very large or unusual dimensions. Stick to the supplied orientations (landscape A4 or portrait A4) for best results. **Q: The AI designer says it can't help.** A: The AI designer needs your collection context (collectionId in the URL). If you opened the admin without that context, the AI won't activate. The visual controls still work normally. **Q: How do I use a different design for some products?** A: Open the **Facets** tab in the admin and add an override key like `tier=gold`. Then customise the fields you want to differ. Products matching that facet will use the override; everything else uses the default design. **Q: Can I add my own logo to the seal?** A: Yes — under **Seal**, paste an image URL. Use a hosted URL (Imgur, your own CDN, or an SL.asset URL). Square images work best. **Q: Can the certificate be more than one page?** A: No — certificates are intentionally single-page (A4) so they print cleanly and read at a glance. --- # App module: Related Products Source: https://docs.smartlinks.app/setup/apps/relatedProducts Learn how to configure the Related Products app to showcase cross-sell items to customers by matching product facets, setting display styles, and defining purchase actions. # Related Products — User Guide A friendly, plain-English guide for admins setting up the Related Products app. ## What does this app do? It shows your customers other products they might like, right next to whatever product they're currently looking at. Think "you may also like" or "complete the range" — the kind of cross-sell you see on most modern shopping experiences. It works by **matching products that share certain characteristics (called facets)** — for example, the same furniture range, the same wood finish, or the same room. You decide which characteristics matter. When a customer taps one of the related products, you choose what happens: open its full SmartLinks page, jump straight to its purchase URL, or show a small detail view with a Buy button. ## Getting started — first-time setup Open the admin page from your collection settings. You'll see three tabs: **Configuration** (do this first), **Per-product overrides** (optional fine-tuning), and **Preview** (see what customers will see). ### Step 1: Pick the matching facets In the **Configuration** tab, tap each facet that defines "related" for your products. **Examples:** A furniture brand might pick `range` and `finish`. An electronics brand might pick `product-line`. A wine producer might pick `vineyard` and `vintage-style`. If your collection has no facets defined yet, you can type facet keys manually — but it's much better to set up proper facets on the collection first. ### Step 2: Set the matching threshold **Minimum shared facets** — how many of your chosen facets a candidate must share. `1` (default) is inclusive; `2`+ is stricter. ### Step 3: How many to show **Max items to show** — between 1 and 50. Default is 6. ### Step 4: Other collections (optional) If your related products live in another collection (e.g. a sister brand or a "Spare Parts" collection), enter those collection IDs comma-separated. ### Step 5: Choose the look - **Card grid** — clean grid of product cards (best for most cases) - **Carousel** — horizontally scrolling row (great when embedded near other content) - **Compact list** — small thumbnails with text (good for accessories or many items) ### Step 6: Choose what happens on tap - **Open SmartLinks page** — jumps to the product's full SmartLinks page - **Open purchase link** — opens the buy URL straight away (most direct route to sale) - **Show details, then buy** — shows a small detail view with a prominent Buy button (best for considered purchases) ### Step 7: Where is the purchase URL? Tell the app which field on each product holds the purchase link. Default: `data.purchaseUrl`. Change to `data.shopUrl`, `extra.buyLink`, etc. if your team stores it elsewhere. If a product has no purchase URL, the Buy button is automatically hidden for that product. ### Step 8: Labels Customise the section heading and button labels — useful for matching your brand voice. Hit **Save configuration** when you're done. ## Day-to-day use (what your customers see) When a customer scans/visits a product: 1. They see your section heading (e.g. "You may also like") 2. Below it, a layout of related products 3. They tap one — the configured behaviour kicks in 4. From the detail view, they can tap "Buy now" to go to your store Views, taps, and buy clicks all flow into your SmartLinks analytics. ## Per-product overrides Sometimes the auto-match isn't perfect for one product. Open the admin page from that specific product and use the **Per-product overrides** tab: - **Pinned** — product IDs that always show first for this product - **Excluded** — product IDs that never show for this product - **Tap behaviour override** — different tap behaviour just for this product - **Purchase URL override** — supply a buy URL when the product doesn't have one stored at the configured path ## Bulk import (CSV) For larger catalogues, use the CSV import in your admin panel. Columns: - `productId` - `pinnedProductIds` (semicolon-separated within the cell) - `excludedProductIds` (semicolon-separated within the cell) - `tapBehaviorOverride` (`smartlinks` | `purchase` | `details-then-buy`) - `purchaseUrlOverride` Example: `prod_001,prod_017;prod_023,prod_099,purchase,` ## Where this app appears - **Widget** — small embeddable preview meant for product pages and listing surfaces - **Full app** — the complete grid view with detail-then-buy support Both share the same configuration. ## FAQ **A product I expected isn't showing.** Check that it shares at least `minSharedFacets` facet values with the source. The **Preview** tab shows which facets matched on every card. **My products don't have facets yet.** Set up facet definitions on the collection first, then assign facet values to products. The app starts working immediately once products have facets. **Can I show related products from a different brand?** Yes — add their collection ID to **Additional collection IDs**. Make sure the facet keys mean the same thing across collections. **The same product is in two collections — will it appear twice?** No, deduplicated by product ID. **Can customers buy without leaving SmartLinks?** No — purchasing happens on your external e-commerce site. This app makes the discovery and link-out as smooth as possible. **How do I see what's converting?** Check analytics for `related-product-view`, `related-product-click`, and `related-product-buy`. Click outcomes carry the target product ID. --- # App module: Receipt Scanner Source: https://docs.smartlinks.app/setup/apps/receiptScanner Manage product ownership and warranty registration by allowing customers to scan receipts and serial numbers for AI-verified digital proof of purchase. # Receipt Wallet — User & Admin Guide ## What this app does Receipt Wallet turns a phone camera into a proof-of-purchase tool. Your customers can photograph a receipt, the product's serial number, and any other supporting evidence, and the app keeps it safely stored against the product they bought. It works in two ways depending on how the customer arrives: - **Claim ownership** — they scanned a generic product code and want to register that they own this specific item (using receipt + serial number as proof). - **Attach to an existing item** — they scanned an item that already has a digital identity (a "proof") and want to add their receipt for warranty / support purposes. It's designed for brands who want to: - Offer hassle-free warranty registration. - Capture verified ownership without manual paperwork. - Have a single, searchable inbox of every customer's proof of purchase. AI does the heavy lifting — reading the receipt, extracting vendor / date / total / serial number, and scoring how legitimate the submission looks. Anything the AI is confident about is auto-verified; anything borderline is queued for you to review. --- ## Getting started — first-time setup 1. Open your collection's admin console and select **Receipt Wallet**. 2. You'll land on the admin with three tabs: **Submissions**, **Settings**, and **Evidence**. 3. Open **Settings** first and configure the app for your brand (see the table below). 4. Click **Save settings**. The app is now live for shoppers. 5. (Optional) Open a specific product, then go to **Evidence** to customise what proof shoppers must upload for *that* product. --- ## Admin Configuration — Settings tab The Settings tab applies to your whole collection. | Setting | What it does | Default | |---|---|---| | **App title** | The name shoppers see at the top of the app. Pick something short and friendly (e.g. "My Warranty Wallet"). | Receipt Wallet | | **Default warranty (months)** | How long the warranty lasts, counted from the purchase date. Used when a product doesn't have its own override. | 24 | | **AI auto-verify threshold (0–1)** | How confident the AI must be before a submission is auto-approved. `0.75` is a sensible default. Lower = more auto-approvals (and risk); higher = more items waiting for your review. | 0.75 | | **Auto-approve everything** | Skips AI scoring entirely and marks every submission as verified. **Not recommended** — only useful for testing. | Off | | **Allow multiple claims per serial** | If on, more than one shopper can claim the same serial number; conflicts are flagged in your inbox for review. If off, the second claim is rejected automatically. | On | | **Support email** | Shown to shoppers on the confirmation screen so they can reach you if something goes wrong. | (blank) | Click **Save settings** after any change. Updates are live immediately. --- ## Admin Configuration — Evidence tab (per product) Open the admin from a specific product and use the **Evidence** tab to control what shoppers must submit for that product. | Field | What it does | |---|---| | **Warranty length (months) — overrides default** | Per-product warranty length. Leave blank to use the collection default. | | **Evidence slots** | The list of items the shopper must (or can) upload. Each slot has a label, a "required" toggle, and an optional hint. | ### Default evidence slots Out of the box, every product asks for: - **Receipt** — required. The shopper uploads a photo or PDF of their receipt. - **Serial number** — optional. A photo of the serial number on the product itself. You can add, rename, or remove slots freely. Common additions: - **Box label** — for branded packaging proof. - **Warranty card** — if you ship a paper card. - **Product photo** — to show condition at registration. For each slot: - **Label** — what the shopper sees as the section heading. - **Required toggle** — if on, the shopper can't submit until they've added at least one file in this slot. - **Hint** — small help text shown under the label (e.g. *"Make sure the total and date are clearly visible"*). Click **Save evidence schema** when done. > Note: the evidence schema is **per-product**. To configure it, you must open the admin from inside a product's context. --- ## Day-to-day usage — what the shopper sees The shopper's flow is the same on phone or desktop. 1. **They land on the app** — usually after scanning a QR code or tapping a link on the product page. The app figures out automatically whether they're claiming ownership or attaching a receipt to an existing item. 2. **They see the upload form** — one card per evidence slot you configured. Each slot has two buttons: **Take photo** (opens the rear camera on a phone) and **Upload file** (for choosing existing photos or PDFs). 3. **They add evidence.** Each file is shown as a thumbnail; a small × removes it. Files over 10MB are rejected. 4. **AI parses the receipt.** As soon as a receipt photo is added, the AI reads it in the background and pre-fills: - Vendor (shop name) - Purchase date - Total - Currency - Serial number (when claiming ownership) 5. **They review and edit.** All AI-extracted fields are editable — anything they change wins over the AI guess. 6. **They tap Submit.** A progress bar shows upload status. 7. **They see a confirmation screen** with: - Status: **Verified**, **Pending review**, or **Conflict**. - The warranty expiry date. - A claim ID for their records. - A "Submit another" button. - The support email if you configured one. The whole flow is mobile-first and takes most shoppers under a minute. --- ## Managing submissions — Submissions tab This is your inbox. Every submission your customers make appears here. ### Filters & search - **Status filters** at the top: `all`, `pending`, `verified`, `rejected`, `conflict`. - **Search box** matches anywhere in the submission (vendor, serial number, product, etc). - **Export CSV** downloads the current list as a spreadsheet. ### Status meanings | Status | What it means | |---|---| | **Pending** | AI score was below your threshold — waiting for you to review. | | **Verified** | Either AI scored above the threshold, or you approved it manually. | | **Rejected** | You rejected it. The optional reason you typed is saved with the record. | | **Conflict** | The serial number has already been claimed by another shopper. Needs your attention. | ### Each submission card shows - Status badge and flow type (`claim` or `attach`). - When it was submitted. - The product (and proof, if attaching). - Vendor, purchase date, total, currency. - Serial number, if provided. - AI score (as a percentage) and the AI's short reasoning. - A grid of evidence thumbnails — click any to open the original file. ### Actions on each submission - **Verify** — approves the submission. Status flips to Verified. - **Reject** — you'll be prompted for a reason; it's saved with the record. Both actions are immediate and update the customer's record. ### Exporting The **Export CSV** button gives you a spreadsheet with: id, status, flow, productId, proofId, vendor, purchaseDate, total, currency, warrantyUntil, aiScore, submittedAt — useful for support reporting or integration with your CRM. --- ## Widget — adding Receipt Wallet to product pages The **ReceiptWalletWidget** can be embedded on any product page in the SmartLinks platform. It shows the latest receipt and warranty status for whichever item is being viewed. It comes in three sizes: | Size | What it shows | |---|---| | **Compact** | One-line summary: app title, warranty-until date or "No claim yet", and an arrow to open the full app. Great for sidebars. | | **Standard** | Receipt thumbnail, status badge (Verified / Pending / Conflict / Rejected), vendor, purchase date, warranty expiry, and a button to open the full app. | | **Large** | Same as standard, plus a count of the total files in the claim. Best for hero placements. | If no claim exists yet, the widget shows a friendly empty state with an **Add receipt** button that takes the shopper into the upload flow. The widget configures itself — no extra setup beyond the initial app configuration. --- ## Bulk import — per-product evidence requirements If you have a large catalogue, you can import evidence requirements and warranty overrides per product using a CSV. ### CSV template ```csv productId,warrantyMonths,evidenceSlots prod_001,24,"Receipt,Serial number" prod_002,60,"Receipt,Serial number,Box label" prod_003,12,"Receipt" ``` ### Field reference | Field | Required | What it does | |---|---|---| | `productId` | ✅ | The SmartLinks product the row applies to. Must match an existing product in this collection. | | `warrantyMonths` | ❌ | Per-product warranty length in months. If blank, the collection default is used. | | `evidenceSlots` | ❌ | Comma-separated list of slot labels (e.g. `Receipt,Serial number,Box label`). Each one is treated as **required**. If blank, the default Receipt + Serial number slots are used. | Rows with a `productId` that doesn't match any product in your collection are skipped — you'll see a summary at the end of the import. --- ## FAQ & troubleshooting **Q: A shopper says they can't tap Submit.** A: The Submit button stays disabled until every **required** evidence slot has at least one file. Check your Evidence tab — you may have more required slots than they realised. Reduce required slots or improve the hint text. **Q: The AI got the vendor / date / total wrong.** A: That's expected occasionally with poor photos. The shopper can edit any field before submitting — their edits always win over the AI guess. If a particular brand of receipt is consistently mis-read, contact support. **Q: Submissions are sitting in "Pending" — none get auto-verified.** A: Your **AI auto-verify threshold** is probably too high. Lower it from `0.75` toward `0.6` to auto-approve more, or temporarily turn on **Auto-approve everything** for testing. **Q: Why was a submission marked "Conflict"?** A: Two different shoppers claimed the same serial number. Open the submission to see both, then either Verify the legitimate one and Reject the other, or contact both customers for clarification. If you don't want to allow this at all, turn off **Allow multiple claims per serial**. **Q: A file upload failed.** A: Files over **10 MB** are rejected at the picker. Ask the shopper to take a slightly smaller photo (most phones have a "low" or "medium" quality option) or scan to PDF. **Q: My CSV import skipped rows.** A: Check that every `productId` matches an existing product in this collection. Misspelled or non-existent IDs are skipped. The Import summary lists each skipped row. **Q: The Evidence tab says "open this from a product context".** A: Evidence requirements are configured **per product**. Open the admin from inside a specific product (so the URL has a productId), then the Evidence tab will be editable. **Q: Can I change settings after launch?** A: Yes — every Setting and Evidence field is editable at any time. Changes apply to all *future* submissions. Existing submissions keep the warranty length and evidence schema they were submitted with. **Q: Where do warranty dates come from?** A: Warranty expiry = purchase date (from the receipt) + warranty months. The product's override is used if set; otherwise the collection default. If the AI can't read a purchase date, the shopper enters it manually. **Q: A shopper didn't get a confirmation email.** A: The app shows a confirmation screen but doesn't send email itself. Make sure your **Support email** is set so they have somewhere to reach you. Email notifications can be added on top of the platform's broadcast tools. --- # App module: Spare Parts Source: https://docs.smartlinks.app/setup/apps/spareParts Learn how to configure and manage a spare parts catalogue that automatically displays matching components and purchase links on your Smartlinks product pages. # Spare Parts Finder — User Guide Welcome! The **Spare Parts Finder** lets you attach a catalogue of spare parts and accessories to your SmartLinks collection, then automatically show the right ones to your customers on each product page. Customers click out to your existing online shop to buy. ## Overview - **Admins** build and maintain a catalogue of spare parts (titles, images, prices, purchase links, and which products they fit). - **Customers** see a carousel of matching parts on the SmartLinks public page for each product. - This app does **not** process payments — every Buy button opens your e-commerce site in a new tab. ## Getting Started When you first set up the app, you'll be asked four questions: 1. **Public heading** — the title shown above the parts list (e.g. *Spare parts & accessories*). 2. **Default currency** — three-letter code like `USD`, `EUR`, `GBP`. Used when a part has a price but no currency. 3. **Default click behaviour** — what happens when a customer clicks a part: - **Direct** — opens the purchase URL straight away (fastest). - **Modal** — shows a detail popup with the full description and a Buy button (better for considered purchases). 4. **Empty-state message** — what to show when no parts match the current product. You can change all of these any time under the **Settings** tab. ## Day-to-Day Usage ### What customers see On any SmartLinks product page that uses this app: - A carousel of spare parts that fit that specific product. - Each card shows an image, title, optional subtitle, optional description excerpt, and price. - A **Buy** button takes them to your online shop in a new tab. If no parts match, they see your friendly empty-state message instead — never an error. ### Where parts can appear - The full **public page** in the SmartLinks portal. - An embeddable **widget** (Spare Parts Widget) in three sizes: - **Compact** — image + title + price + Buy. Great for sidebars. - **Standard / Large** — rich cards with description. - A full-app **container** if a partner wants to embed the entire experience. ## Admin Configuration The admin has three tabs. ### 1. Catalogue The list of every spare part. From here you can: - **Add spare part** — open a form to create one. - **Edit** — change any field. - **Delete** — remove a part (with confirmation). - **Search** — filter by title, SKU, or GTIN. #### Spare part fields | Field | What it does | |---|---| | **Title** *(required)* | Main name shown to the customer. | | **Subtitle** | Short tagline under the title. | | **Description** | Longer text shown in the detail modal. | | **Image URL** | Direct link to a publicly-hosted image. | | **Purchase URL** *(required)* | Where the Buy button sends the customer. | | **Price + Currency** | Optional. If you only enter an amount, the default currency is used. | | **Product IDs** | Comma-separated list of SmartLinks product ids this part fits. | | **GTINs** | Comma-separated list of GTINs (barcodes) this part fits. | | **SKUs** | Comma-separated list of SKUs this part fits. | | **Facet expression** | A flexible rule based on product facets (see below). | | **Click behaviour** | Override the global setting just for this part. | | **Internal notes** | Admin-only memo, never visible to customers. | A part is shown on a product when **any** of: a matching ID, GTIN, SKU, or a facet expression that evaluates to true. A part with none of these never appears. #### Facet expressions Facet expressions let you say things like *"this part fits any X-series model from 2020 onwards"* without listing every product id. - Operators: `=`, `!=`, `>`, `<`, `>=`, `<=` - Combine with `AND`, `OR`, `NOT`, and parentheses - Wrap text in quotes when it has spaces Examples: ``` model = "X200" model = "X200" AND year >= 2020 (category = "interior" OR category = "trim") AND NOT discontinued = true ``` The form validates your expression as you type and warns if there's a typo. ### 2. Import / Export Manage the catalogue in bulk with CSV. - **Download template** — a sample CSV with the right columns and two example rows. - **Export catalogue** — download everything currently in your catalogue. - **Import CSV** — upload a file. The app shows live progress and a summary at the end (added, updated, failed). Imports **upsert by id**: - A row with an `id` that already exists in your catalogue → that part is **updated**. - A row with no `id` (or a new id) → a new part is **added**. Multi-value columns (`productIds`, `gtins`, `skus`) use the pipe character `|` as the separator, e.g. `prod_001|prod_002`. If a row has invalid data (missing title, bad facet expression, non-numeric price), it's reported and skipped — the rest of the import continues. ### 3. Settings The same four questions from setup. Update any of them, click Save, and the changes take effect immediately. ## Import / Bulk Setup CSV columns: ``` id,title,subtitle,description,imageUrl,priceAmount,priceCurrency, purchaseUrl,productIds,gtins,skus,facetExpression,clickBehaviour,internalNotes ``` A starter row: ``` ,Brake Pad Set,Front,Ceramic pads,https://example.com/pad.jpg, 49.99,USD,https://shop.example.com/pad-001,prod_001|prod_002,,SKU-PAD-FRONT,,direct, ``` (Leave `id` blank for new rows.) ## Widgets The **Spare Parts Widget** is the embeddable version of the public carousel. - **Compact** — minimal cards, optimised for sidebars and small spaces. - **Standard / Large** — full rich cards with description. Widgets show the same matched-parts logic as the public page, so you only manage the catalogue in one place. ## FAQ / Troubleshooting **A part I added isn't appearing on the product page.** Check the part's matches: at least one of Product IDs, GTINs, SKUs, or a Facet expression must apply to the current product. A part with no match criteria is never shown. **My facet expression isn't matching.** Confirm the facet key and value on the product itself. String comparisons are case-insensitive but the facet key must match exactly. Use the form's validator to catch syntax issues. **My CSV import says "0 added".** Open the failures list. The most common causes are a missing title, missing purchase URL, or an invalid facet expression. **Prices show in the wrong currency.** Either set a `priceCurrency` per row in the CSV, or update the **Default currency** in Settings. **Customers reach my shop in the wrong language.** The Buy button opens whatever URL you provide in `purchaseUrl`. If you have localised shop URLs, store the relevant one per part. **I want to remove the entire catalogue.** Delete parts individually from the Catalogue tab. Imports only add or update — they never delete missing rows, so an "empty" import won't wipe your data. --- # App module: Ownership Checker Source: https://docs.smartlinks.app/setup/apps/ownershipCheck Learn how to set up and manage the Ownership Check app to facilitate secure, brand-verified ownership handshakes between item owners and potential buyers. # Ownership Check — User & Admin Guide A friendly, plain-English manual for brand managers, marketing teams, and admins running the **Ownership Check** app. --- ## What this app does Ownership Check is a simple two-party handshake between a **verifier** (someone who wants to confirm a product is genuinely owned — for example, a buyer about to purchase a second-hand item) and an **owner** (the person registered against that product on your platform). A typical scenario: > A buyer is about to pay for a watch listed on eBay. Before sending money, they want to be sure the seller actually owns it. They open the watch's product page, click **"Request an ownership check"**, fill in a short form, and the real owner is notified. The owner approves the request and the buyer receives a public verification page (with a short code they can read out, a one-time URL, and an optional note from the owner) that proves the seller is genuine — at that moment in time. It is designed for collectibles, luxury goods, second-hand marketplaces, art, watches — anywhere ownership matters and counterfeits or scams are a risk. **Who it's for** - **Owners / collectors** — respond to verifier requests in one click. - **Verifiers / buyers** — get a trusted answer before spending money. - **You (the brand)** — provide the trust layer between them, with an audit trail of every request. --- ## Getting started The first time you open the admin console you'll be walked through a short setup wizard. You only need to do this once per collection. ### Step 1 — Open the admin console Open your collection in the platform and launch the **Ownership Check** admin. You'll land on a four-tab dashboard: **Overview**, **Requests**, **Listings**, **Settings**. ### Step 2 — Run the setup wizard The wizard asks you for a handful of choices. You can change every one of them later from the **Settings** tab, so don't agonise over them. | Setup question | What to enter | Recommended | | --- | --- | --- | | Sender name | The name shown on emails to verifiers and owners | Your brand or collection name (2–4 words) | | Verification page style | How the public proof page looks | **Detailed** (good middle ground) | | Show owner's note | Display the optional owner-written message on the proof page | On | | Show verifier's reason | Display why the verifier asked | On | | Show verifier's name | Display the verifier's name on the proof page | Off (privacy) | | Artifact lifetime (hours) | How long an approved proof stays valid | 24 | | Request expiry (days) | How long a request waits before auto-expiring | 7 | | Confirm-link lifetime (hours) | How long the verifier's email confirmation link works | 1 | | Register notification topics | Sets up the four required email templates | Yes | When you save, the app is live. Every product in the collection is immediately request-able. --- ## The four admin tabs ### Overview A live KPI dashboard. Numbers refresh automatically every 60 seconds. | KPI | What it tells you | | --- | --- | | **Pending requests** | Requests waiting for an owner to act on them right now | | **Total submitted** | All-time count of verifier requests | | **Confirmation rate** | Percentage of verifiers who clicked their email confirmation link | | **Approval rate** | Percentage of confirmed requests that owners approved | | **Approved** | Total approvals all-time | | **Declined** | Total declines all-time | | **Verifications viewed** | How many times public proof pages were opened | | **Total confirmed** | How many requests got past the email-confirmation step | A low **confirmation rate** usually means verifiers are mistyping emails or not finding the confirmation message. A low **approval rate** can mean owners aren't seeing notifications or are receiving suspicious-looking requests. ### Requests Your operations queue. Every proof-of-ownership request your collection has ever received lives here. **Filtering** - **Search box** — filter by request ID, proof ID, verifier name, or verifier email. - **Status dropdown** — narrow by status. **Statuses** | Status | Meaning | | --- | --- | | Awaiting confirmation | Verifier submitted the form but hasn't clicked their email link yet | | Pending owner | Verifier confirmed; the owner has been notified and needs to approve or decline | | Approved | Owner approved; verifier has a working proof page | | Declined | Owner declined the request | | Expired | Nobody acted in time — auto-closed | | Revoked | Owner (or you) revoked a previously-approved proof | **Inspecting a request** Click any row to open a detail panel showing: - The verifier's name, email, and the reason they gave - Which proof and product the request is about - The listing URL the verifier supplied (if any), and whether it matched an owner-declared listing - The artifacts issued (short code + expiry) if approved - A full audit log — every state change with timestamp and actor **Revoking an approved proof** If you discover a proof was approved in error (for example the owner clicked too quickly, or you suspect fraud), open the request and click **Revoke artifact**. The public proof page stops working immediately and the verifier will see a "no longer valid" state. ### Listings Owners can declare upfront where their items are listed for sale (eBay, Etsy, Vinted, Depop, Facebook Marketplace, their own website, or other). When a verifier later requests an ownership check and supplies a listing URL, the app automatically checks whether it matches one the owner has registered. This helps the owner decide quickly whether the request is legitimate. This tab lets you (the admin) manage these listings on a specific proof. **Managing listings** 1. Enter the **Product ID** and **Proof ID**, then click **Load listings**. 2. The existing listings appear, each showing platform, expiry date, and a clickable URL. Expired listings are clearly badged. 3. Use **Add a listing** to register a new one — pick a platform, paste the URL, and set an expiry date. Optional notes (e.g. "Auction ends Friday") are supported. 4. Click the trash icon to remove a listing. ### Settings Edit any of the wizard answers post-launch. Changes take effect immediately for new requests. | Section | What you can edit | | --- | --- | | **Sender** | Name shown on outgoing email and SMS | | **Verification page** | The default look of the public proof page — and which fields it shows | | **Lifecycle defaults** | Artifact, request expiry, and confirmation-link timings | **Verification page styles** | Style | Best for | | --- | --- | | **Minimal** | Just a verified badge and timestamp — fast and modern | | **Detailed** | Item info, owner note, reason, expiry — most informative (default) | | **Certificate** | A formal, printable document layout — best for high-value items | **Field toggles** let you choose what the public verification page reveals. Toggle off **Verifier's name** if your verifiers prefer to stay private. **Lifecycle defaults** | Field | Notes | | --- | --- | | Artifact TTL (hours) | How long an approved proof remains valid. 24 is a good default; raise to 72 for slow-moving high-value sales | | Request expiry (days) | How long a request waits for owner action before auto-expiring. 7 days is standard | | Confirm token TTL (hours) | How long the email confirmation link stays clickable. 1 hour is typical for security | Click **Save settings** at the bottom. A toast confirms success. --- ## What verifiers experience (the public flow) Here's exactly what someone scanning a SmartLink and requesting an ownership check sees. 1. **Land on the proof page** — they arrive on the product's public SmartLink. 2. **Click "Request an ownership check"** (the embedded widget — see below). 3. **Fill in a short form**: - **Your name** (required) - **Your email** (required) - **Reason for the request** (required, at least 8 characters — e.g. "I'm buying this on eBay and want to verify the seller") - **Listing URL** (optional — but if the owner has declared listings, this becomes required and helps confirm legitimacy) 4. **See a "check your email" confirmation screen.** 5. **Open the email** — it contains a confirmation link valid for the time you set (default 1 hour). 6. **Click the link** — the request flips from "Awaiting confirmation" to "Pending owner" and the owner is notified through their preferred channel. 7. **Wait for the owner** — once approved, the verifier receives a result email containing: - A **short code** (e.g. `7K9-QF2X-MN3P`) they can read aloud to confirm authenticity - A **one-time URL** to a public verification page - An optional **signed note** from the owner If the verifier mistypes the URL, lets the link expire, or the owner declines, they see a clear, plain-language error and can start over. --- ## Widgets The app ships with one embeddable widget you can drop onto any product or proof page. | Widget | Purpose | | --- | --- | | **Request an ownership check CTA** | A "Request an ownership check" button that takes the verifier into the request flow | It comes in three sizes: - **Compact** — minimal footprint, fits in sidebars or sticky bars - **Standard** — default size, fits most product page layouts - **Large** — expanded variant with extra context for the verifier No extra setup needed beyond the initial wizard — the widget picks up your sender name and styling automatically. --- ## Bulk-importing listings If you manage many proofs and want to seed listings in bulk (for example after an auction or marketplace sync), use the CSV import on the Listings tab. ### CSV template ```csv proofId,productId,platform,url,expiresAt,note prf_001,prod_paint_a,ebay,https://www.ebay.co.uk/itm/123456789,2026-12-31,Auction ends Friday prf_002,prod_paint_a,etsy,https://www.etsy.com/listing/987654,2026-06-30, prf_003,prod_watch_b,own-website,https://example.com/sale/watch,2026-09-15,Direct sale ``` ### Field reference | Field | Required | Type | Notes | | --- | --- | --- | --- | | `proofId` | Yes | text | The proof this listing attaches to | | `productId` | Yes | text | The product containing the proof | | `platform` | Yes | choice | One of: `ebay`, `etsy`, `vinted`, `depop`, `facebook-marketplace`, `own-website`, `other` | | `url` | Yes | URL | Canonical, public listing URL | | `expiresAt` | Yes | date | ISO date (`YYYY-MM-DD`). Listings are ignored after this date | | `note` | No | text | Free-text reminder visible only to admins/owners | Rows with unknown `proofId` or `productId` are skipped — you'll see a summary after import showing how many succeeded and which rows failed. --- ## FAQ & troubleshooting **Why didn't the verifier receive a confirmation email?** Check their email is spelled correctly (case-insensitive but exact otherwise). If you maintain a suppression list, their address or domain may be on it — in that case the request is recorded but no email is sent (this is intentional, to avoid leaking your suppression rules). Also confirm in **Settings** that the four notification topics were registered during setup. **The verifier clicked the link but got an "expired" or "already used" error.** Confirmation links are single-use and time-limited. If the link expired (default 1 hour) or was clicked twice, the verifier needs to start again from the request form. You can lengthen the **Confirm-link lifetime** in Settings if this is a frequent problem. **The owner says they never got notified.** Open the request in the **Requests** tab and check the audit log — you'll see whether a `notified-owner` entry was recorded. If it was, the issue is with the owner's preferred notification channel (email filters, push permissions). If it wasn't, the proof may not have an owner contact registered — they need to claim the proof first. **Can I change the verification page style after a request has already been approved?** Yes — style and field-visibility changes apply immediately to all proof pages, including ones already issued. Lifetimes (TTLs) only affect new requests; already-issued artifacts keep the expiry they were issued with. **A request is stuck on "Awaiting confirmation" — can I push it forward manually?** No, by design. The verifier must prove they own the email address. If they can't find the email, they should resubmit the form. **My CSV import skipped some rows.** Check that every `proofId` in your CSV matches an existing proof in the collection, and every `productId` matches a real product. Also confirm your `expiresAt` values are valid ISO dates (`YYYY-MM-DD`) and your `platform` values are from the allowed list. **A buyer is challenging an approved proof — how do I revoke it?** Open the request in the **Requests** tab, scroll to the bottom of the detail panel, and click **Revoke artifact**. The public verification page becomes invalid immediately and the audit log records who revoked it and when. **How long is the audit trail kept?** Forever — every state change on every request is appended to the case's audit log and is visible in the Requests detail panel. **Can I delete a request entirely?** No — requests are part of your audit trail. You can revoke approved artifacts, but the case record itself is preserved for traceability. --- # App module: Feedback Source: https://docs.smartlinks.app/setup/apps/feedback Learn how to set up the Feedback app to collect private, structured customer insights using customizable forms and managed identity settings. # Feedback — User Guide ## What is the Feedback app? Feedback lets you collect structured, private feedback from your customers — anchored to a specific product. It's different from public reviews (which go to other shoppers) and from support tickets (which are about something being wrong). Feedback is for *"tell us what you think"* — sizing, taste, comfort, first impressions, or anything you want to ask. Use it for size-and-fit feedback on clothing, taste notes on food, comfort surveys for furniture, or any structured customer voice you'd like to capture. --- ## Getting Started When you first open the admin panel, you'll see the **setup wizard**: 1. **Pick a starting template** — five ready-made forms cover the most common cases (general, size & fit, taste & quality, comfort & use, first impressions), plus a blank "Build from scratch" option. 2. **Choose how to handle user identity:** - **Always anonymous** — no name or email collected - **Optional** — users can leave contact details if they want a reply - **Required** — users must provide name and email 3. Click **Get started**. Your form is live. --- ## Day-to-day Usage Customers see your form whenever they open the SmartLinks page for a product in this collection. They fill it in and hit send — submissions land in your **Inbox**. Feedback is **private by default** — only your team sees it. --- ## Admin Configuration The admin panel has three tabs: ### Inbox A feed of every submission across the collection. Each card shows the submitter (or "anonymous"), the product, when it was sent, and the first couple of answers. ### Form Edit the form title, intro, and questions. Add, remove, reorder, or edit any field. Available field types: - Short text - Long text - Star rating (1–5 or up to 10) - Number scale (e.g. 0–10 NPS-style) - Single choice - Multiple choice - Yes / No Click any question to edit its label, help text, requirements, and options — with a live preview. ### Settings - **Identity** — change anonymous / optional / required - **Notifications** — none, on each submission, or daily digest (delivered via SmartLinks comms) - **Replies** — enable two-way email replies to submitters --- ## Coming next (in development) - Submission detail view with full responses - Two-way email replies via SmartLinks comms - Tags for triage - AI-powered summary of recent feedback - CSV export - Post-submit CTAs (link to competitions, mailing list, discount codes) --- ## FAQ **Q: Where is the feedback stored?** A: Each submission is an attestation on the product (or specific proof, if scanned). It's marked private — only admins can read it. **Q: Can I have different forms per product?** A: Currently feedback is configured per-collection. Per-product overrides are on the roadmap. **Q: How do customers receive my reply?** A: When admin replies launch, we'll send via SmartLinks comms (email) and provide an in-app thread page they can return to. **Q: Is this the same as the NPS app?** A: No — NPS is a single 0–10 score. Feedback is for structured forms with multiple field types, optional contact, and replies. Use both side-by-side. --- # App module: Social Links Source: https://docs.smartlinks.app/setup/apps/socialLinks Learn how to configure and display your brand's social media profiles across the Smartlinks platform with localized, conditional visibility for different audiences. # Social Links — User Guide Welcome! **Social Links** is the friendly little app that gathers all of your brand's social profiles — Instagram, TikTok, YouTube, LinkedIn, X, Facebook, and friends — into one tidy, on-brand list that you can drop almost anywhere your customers might be looking. This guide walks you through everything you need to know, in plain language. No jargon, promise. --- ## What is Social Links? Social Links is a small, focused app with one job: **show people where to follow your brand**. You configure your profiles once, and they appear: - on a clean, mobile-friendly **public page** - inside **widgets** (small previews) you can drop on a homepage, a product page, or a hub - inside **containers** (the full experience embedded right in another page — no iframe needed) - in **footers, link-in-bio pages, landing pages**, or anywhere else the SmartLinks platform lets you embed an app The clever bit: you can attach **conditions** to any link, so different profiles show up for different audiences — by language, country, or device. One setup, many local experiences. --- ## Getting started (the 2-minute setup) 1. Open the **Social Links admin** for your collection. 2. Click **Add link** and pick a platform from the list (Instagram, TikTok, YouTube, X, Facebook, LinkedIn, Threads, Pinterest, Snapchat, WhatsApp, Telegram, Discord, Twitch, GitHub, Spotify, Apple Music, and more). 3. Paste in the profile URL (e.g. `https://instagram.com/yourbrand`). 4. Optionally tweak: - **Label** — a friendly name shown to users (defaults to the platform name, in their language) - **Call-to-action** — e.g. "Follow us", "Watch on YouTube" 5. Drag links to reorder them — the top one shows first. 6. Hit **Save**. That's it. Your changes appear live on every surface. You'll see a **live preview** on the right of the admin while you edit, so you always know what your audience sees. --- ## Day-to-day usage ### Editing a link Click any link row, change the URL, label, or CTA, and save. Changes go live immediately — no rebuild, no wait. ### Reordering Grab the handle on the left of any link and drag. The order you see is the order your audience sees. ### Removing a link Click the trash icon. (Don't worry — you're only hiding the link from this app's config, not deleting your actual social account!) ### Hiding a link temporarily Toggle it off instead of deleting. Handy for seasonal campaigns or accounts you're pausing. --- ## Conditional links (the secret weapon) This is where Social Links gets clever. Each link has a **Conditions** button. Click it and you can say things like: - *"Only show this WeChat link to visitors in China."* - *"Show this French Facebook page only when the page is in French."* - *"Hide TikTok on desktop, show it on mobile."* - *"Only show our LinkedIn between 9am and 6pm on weekdays."* (okay, you probably won't, but you could) **Why this matters:** a global brand can keep a single set of social links and still send people to the *right* local profile. No duplicate apps, no messy logic — one config, many audiences. Conditions support: - **Language** — based on the viewer's chosen language - **Country / region** — derived from the viewer's locale - **Device** — phone, tablet, desktop - **Date / time windows** — for campaigns If a link has no conditions, it shows to everyone. Simple. --- ## Where Social Links can appear ### 1. The public page (full experience) A standalone, mobile-first page at your app's public URL. Great for QR codes, link-in-bio, business cards, and printed materials. ### 2. Widgets (lightweight previews) Drop a small, embeddable preview into another SmartLinks app, a portal homepage, a product page, or a brand hub. Three sizes: | Size | What it looks like | Best for | |---|---|---| | **Compact** | Just the icons | Footers, dense layouts | | **Standard** | Icons with platform names | Sidebars, cards | | **Large** | Icons, names, and CTAs | Hero sections, dedicated panels | Widgets are tiny (~10KB) and load instantly. ### 3. Containers (full app, embedded) If you want the *full* Social Links experience inside another page (no iframe, no popup), use the container. The host page passes context in, and Social Links renders inline. ### 4. Footers, hubs, and partner pages Because widgets and containers are standard SmartLinks bundles, any host that supports SmartLinks embeds can host them: site footers, partner microsites, brand hubs, in-app drawers, you name it. --- ## Theming Social Links inherits the brand theme from the parent platform automatically — colors, fonts, dark mode. You don't need to configure anything. If your brand's theme changes, Social Links follows along. The admin uses a neutral palette on purpose, so it never fights with the host platform's chrome. --- ## Tips & best practices - **Keep it short.** 4–8 links is the sweet spot. More than that and people stop scanning. - **Lead with your strongest channel.** Put the platform you're most active on first. - **Use clear CTAs.** "Follow us on Instagram" beats "Instagram" every time. - **Match the audience.** Use conditions to route Japanese visitors to your JP TikTok, English visitors to your global one. - **Test the widget sizes.** Compact looks great in footers; large is better for hero sections. - **Preview before you publish.** The live preview in the admin reflects exactly what users will see. --- ## FAQ **Do I need to add tracking codes to my links?** No. Social Links uses the platform's session-based tracking automatically. Just paste plain URLs. **Can I add a custom platform that isn't in the list?** Yes — choose **Custom** as the platform, give it a label and an icon (or upload your own), and paste the URL. **What happens if a link's conditions don't match?** It's silently hidden for that viewer. Other links still show. **Can two team members edit at the same time?** The last save wins. We recommend coordinating — the changes are quick, so it's rarely an issue. **Is there a mobile admin?** Not yet. Social Links is a display app — there's no on-the-ground operator workflow that requires a mobile admin. If that ever changes, we'll add one. **How do I embed a widget on my own site?** Ask your SmartLinks platform admin — they can generate the embed snippet for you, choosing the size and which collection to point at. --- ## Need help? Reach out to your SmartLinks contact, or check the docs at [docs.smartlinks.app](https://docs.smartlinks.app/apps/social-links). Happy linking! 🔗 --- # App module: Referrals Source: https://docs.smartlinks.app/setup/apps/referral Set up and manage a Shopify-integrated referral programme where customers earn commissions for driving sales via personal discount codes and track performance or payouts. # Referral Programme — Admin Guide Welcome! This guide walks you through setting up and running your referral programme from start to finish. No technical knowledge required — if you can manage your Shopify store, you can run this. ## What this app does It turns your customers into a sales team. Each customer who owns one of your products gets their own personal discount code. When they share that code with a friend, the friend gets a discount at checkout, and the original customer earns a commission. You stay in control of the discount amount, the commission rate, which products are eligible, and when referrers get paid. In short: **buyer gets a deal, referrer gets paid, you get a new sale.** ## Getting started — the five-minute setup When you first open the admin area, you'll see a **Setup Guide** that walks you through everything below. You can also jump to any section directly using the tabs along the top. ### 1. Connect your Shopify store Open **Setup → Shopify Config** and enter your store details. This lets the app create discount codes for your referrers and read your orders so it can track which sales came from which referral. ### 2. Choose your discount and commission Open **Setup → Discount Settings** and decide: - **Buyer discount** — what the friend gets at checkout. Either a percentage (e.g. 10% off) or a fixed amount (e.g. £5 off). - **Referrer reward** — the commission percentage your referrer earns on each qualifying order. - **Commission basis** — whether commission is calculated on the price the customer actually paid (after discount) or on the original list price. "Actual paid price" is the most common choice. - **Currency** — defaults to GBP. Change it to match your store. ### 3. Pick which products are included Open **Setup → Products** and tick the products you want to include in the programme. Only customers who own one of these products will be issued a referral code. ### 4. Tell your customers about it Once setup is complete, eligible customers will see their personal referral code on their product page. They can copy it, share it via email, or send a direct link to a friend. The discount is applied automatically when their friend reaches checkout. That's it — your programme is live. ## Day-to-day usage ### Earnings tab Shows every individual sale that earned a referrer a commission, with the order details, the buyer discount used, and the commission amount. Filter by referrer or date to dig into the numbers. ### Sync & Orders tab Most of the time, new orders are picked up automatically. If you want to force a fresh check — for example, after running a big campaign — use the **Sync** button here. You can also look up any individual referral code to see who owns it and how it's been used. ### Owners tab A complete directory of everyone in your programme: who they are, how many sales they've driven, how much they've earned, and their PayPal email for payouts. Use this to spot your top performers. ### Payouts tab When you're ready to pay your referrers, this is where you do it. 1. Click **Create new payout batch** — this groups all unpaid commissions into a single batch, one line per referrer (so if someone has earned five times, you only pay them once). 2. Review the batch — you can mark individual lines as paid as you process them, or undo a payment if you change your mind. 3. **Download CSV** to import into PayPal's mass-pay tool, or use the PayPal email address shown to send payments manually. 4. Once everyone in the batch has been paid, the batch is complete and the rewards move out of "pending". ## Configuration in detail ### Page Content Open **Setup → Page Content** to customise what your customers see — headlines, descriptions, and any promotional copy on the public referral page. Keep it on-brand and easy to understand. ### Discount tiers (optional) If you want to reward your most loyal customers more generously, you can set up tiered rewards. Tiers are based on a label you assign to each customer (for example, "Bronze", "Silver", "Gold"), and each tier can have its own buyer discount and referrer reward percentages. If you're not sure whether you need this, skip it — the default settings work for most programmes. ### Public product discounts You can also offer a general discount on your product pages for visitors who arrived without a referral code. This isn't tracked to any individual referrer — it's just a way to convert browsers into buyers. Toggle it on in **Discount Settings**. ### Customising what referrers can change Referrers can update their own PayPal email address from their personal dashboard so payouts go to the right place. They cannot change anything else — discount codes, commission rates and order data are all locked down to you. ## Tips for running a successful programme - **Start with a generous buyer discount.** 10–15% off makes the offer feel real and gives the referrer something genuinely worth sharing. - **Don't be stingy with commission.** A 10% referrer reward turns occasional customers into active promoters. - **Pay out regularly.** Monthly payouts keep referrers engaged. Long delays kill enthusiasm. - **Watch the Owners tab.** Your top 10% of referrers will likely drive most of your sales — consider thanking them personally or offering a higher tier. - **Keep the page copy human.** The public referral page is your pitch to the friend. Make it warm, not corporate. ## Troubleshooting **An order didn't appear in earnings.** Check that the customer used a referral code at checkout. Orders without a tracked code don't generate commission. **Commission amount looks wrong.** Open **Discount Settings** and check your **Commission basis**. "Actual paid price" calculates commission on the discounted total; "Listed price" calculates on the original price. **A referrer says their code isn't working.** Open **Sync & Orders → Look up referral code** and paste their code in. You'll see whether it's active and which products it applies to. Re-issue if needed. **A customer's PayPal email is missing.** Their payout line will flag this. Ask them to log into their dashboard and add it — they can do this themselves. **I ran the sync twice — will referrers be paid twice?** No. The sync ignores any order it has already processed. ## Need a hand? If something isn't behaving the way you expect, the **Setup Guide** (top right of the Setup tab) walks through the configuration step by step and is the quickest way to spot a missing setting. For anything else, get in touch with your SmartLinks contact. --- # App module: Photo Stream Source: https://docs.smartlinks.app/setup/apps/photoStream Learn how to set up and manage a live event photo wall, including moderation workflows, display customization, and hardware setup for big-screen TV streams. # Photo Stream — User Guide Turn any event into a live, big-screen photo wall. Attendees snap a selfie on their phone, and within seconds it appears on the TV — mixed with your sponsor graphics, a branded welcome slide, and a scan-to-join QR code so anyone in the room can join in. This guide walks you through everything an organiser needs: setup, running the event, moderating, and troubleshooting. --- ## The three surfaces Photo Stream has three places things happen. You'll use all of them at a typical event. | Surface | Who uses it | Where it lives | | --- | --- | --- | | **Customer page / widget** | Attendees taking photos | Their phones (scanned from a product or the TV's QR code) | | **TV stream** | Nobody touches it — it just plays | A big screen in the room (`/#/stream`) | | **Admin panel** | You, the organiser | Desktop for setup, phone for moderation on the day | --- ## Getting started (5 minutes) 1. Open the **admin panel** for your event collection. 2. Work through the tabs along the left — each one is a small, focused decision. You can come back and tweak any of them later. 3. Click **Save changes** at the bottom. 4. Open the **TV view** on the screen running the event and bookmark it (or scan the QR code in the **Access & sharing** tab to send the link to whoever is at the venue). That's it — you're live. The rest of this guide explains each tab and the day-of workflow in detail. --- ## Admin tabs explained ### Event The basic identity of the stream. - **Event title** — shown on the TV header and on the customer submit page. Keep it short and recognisable ("Acme Summer Party 2026"). ### Moderation Decide whether photos appear instantly or get a human check first. - **Pre-approve photos** — leave **off** for instant fun (recommended for trusted audiences). Switch **on** for events where every photo needs a quick eyeball before going on screen. Pending photos sit in the mobile moderation queue until approved. ### Access & sharing Generate the link and QR code your venue staff need. - **Stream access key** — a short token added to the TV URL so only people with the link can open the stream. Regenerate it any time to lock out a previously shared link. - **Share link / QR** — copy the link, or have venue staff scan the QR with the device that will run the TV. ### Display How the slideshow looks and feels. - **Layout mode** - **Single** — one big photo at a time, with fade / slide / zoom / Ken-Burns transitions. Most cinematic. - **Grid** — four photos at once. Best for high-volume events where lots of people want to see themselves quickly. - **Mix** — alternates between the two. A good default. - **Seconds per slide** — how long each slide stays up. 5–8 seconds works well for most rooms. - **Sponsor share of rotation** — what percentage of slides should be sponsor graphics instead of attendee photos. 15–25% is typical. ### Welcome slide The first impression — what's on screen before any photos arrive, and periodically through the night. - **Welcome title** — large headline ("Welcome to the Acme Summer Party"). - **Logo** — your event or brand mark, shown over the background. - **Background image** — full-bleed hero image. Landscape, high resolution. The welcome slide also reappears every few rotations so latecomers always see the branding. ### Footer & QR A persistent strip across the bottom of the TV stream that invites people to join in. - **Footer text** — short call to action ("Scan to add your photo"). - **Footer logo** — small mark on the opposite side of the QR. - **Scan-to-submit QR** — auto-generated. When scanned, it opens the customer page with the camera ready to fire. Anyone in the room can join, even without scanning a product. ### Sponsors Upload the graphics that get interleaved into the rotation. - Use the **asset picker** to add sponsor images (PNG or JPG, landscape works best, auto-compressed to 1920px wide). - Order matters — the rotation cycles through them in the order shown. --- ## On the day ### Setting up the room 1. Open the **TV stream URL** on the screen device. Switch the browser to full-screen (F11 / Cmd-Ctrl-F). 2. Check that the welcome slide appears and the QR in the footer scans cleanly from a few metres away. 3. Open the **mobile admin** on your phone and keep it handy. ### Customer flow When an attendee scans a product (or the TV's footer QR), they see a big **Take a selfie** button. They snap, hit **Send to stream**, and: - **Auto** mode — the photo lands on the TV within ~8 seconds. - **Pending** mode — it queues for your approval first. Landscape photos fill the screen in **Single** mode; portraits get paired up in grids. The customer is told this on the capture screen so they can choose. ### Moderating from your phone The mobile admin shows two tabs: - **Pending** — photos awaiting approval. Tap one to **Approve** (it goes live) or **Delete** (it's gone forever). - **Live** — everything currently on the stream. Use this to remove anything inappropriate after the fact. Big tap targets, designed to be used one-handed while you walk around the venue. --- ## FAQ **Photos aren't appearing on the stream.** Check the **Moderation** tab. If it's set to *Pre-approve*, photos need approval before going live. Otherwise the stream polls roughly every 8 seconds — give it a moment. **Someone uploaded something inappropriate.** Open the mobile admin → **Live** tab → tap the photo → **Delete**. It vanishes from the rotation immediately. **The QR in the footer doesn't work.** Make sure the **Footer & QR** tab has a working URL configured (it defaults to the public submit page for this collection). Test it with your own phone before doors open. **Can I change settings during the event?** Yes — every setting in the admin tabs takes effect on the next stream poll (within ~10 seconds). You can re-order sponsors, swap the welcome image, or flip moderation modes live. **How big can sponsor / welcome images be?** Anything reasonable — they're auto-compressed to 1920px on upload. Landscape PNG/JPG renders best on the TV. **Can people submit without scanning a product?** Yes — that's exactly what the **Footer & QR** is for. The QR on the TV opens the camera directly, no product scan required. **Where do the photos go after the event?** They're stored as records in your collection and remain accessible via the admin. You can export or delete them at any time. --- ## Tips for a great-looking stream - **Test the TV before doors open.** Different displays render colours differently — check that the welcome image and sponsor logos look right on the actual screen. - **Mix landscape and portrait sponsors.** Landscape sponsor graphics shine in Single mode; portrait ones balance grids. - **Don't over-tune the rotation speed.** 6 seconds per slide is the sweet spot — fast enough to feel alive, slow enough to actually see faces. - **Use the welcome slide as a "rest" beat.** A 20% sponsor ratio plus the periodic welcome reappearance keeps the stream from feeling like an ad reel. - **Keep moderation off if you can.** Instant gratification is the magic of Photo Stream — only switch to Pre-approve if you genuinely need it. --- # App module: Care Label Source: https://docs.smartlinks.app/setup/apps/careLabel Learn how to configure digital care labels using a tiered system of collection defaults, facet rules, and product overrides to provide multilingual laundry instructions. # Care Instructions — Your Friendly Guide Welcome! This guide explains how the Care Instructions app works in plain language — no jargon. Whether you're setting up care labels for your products or just curious how shoppers see them, this is for you. --- ## What this app is for Every washable product — a t-shirt, a bedsheet, a tea towel — needs care instructions. Traditionally those tiny symbols are sewn onto a label inside the garment, often illegible and only in one language. This app gives you a modern alternative: a clear, colourful, multilingual care page that your customers can pull up on their phone. They see exactly how to wash, dry, iron, bleach, and clean each item — in English, German, or French. For you, it's also a smart way to manage care information across a big catalogue without editing every product one by one. --- ## How care labels are organised The clever part is how care labels are *applied*. You don't have to write instructions for every single product. Instead, you set them at three different levels, and the app picks the right one automatically. ### The three levels **1. The catalogue-wide default** Think of this as your safety net. It's one set of instructions that applies to everything in your collection unless you say otherwise. Most brands start with something gentle and sensible like "wash at 30°, line dry, iron low". **2. Rules based on product traits** You can write rules that apply to groups of products sharing certain traits — for example, "everything made of silk" or "all swimwear". When a product matches a rule, the rule's instructions are used. **3. A specific product's own label** Sometimes one product is just unusual and needs its own instructions. You can attach a label directly to that single product and it overrides everything else. ### How the app chooses When a shopper views a product, the app looks for the most specific match first: 1. Does this exact product have its own label? Use that. 2. If not — does it match any rules? Use the most specific matching rule. 3. If not — fall back to the catalogue-wide default. This means you set up the common stuff once, write a handful of rules for special cases, and only ever fuss with individual products when you really need to. --- ## Setting up your care labels (the admin side) When you open the app's admin area, you'll see a clean workspace with three sections corresponding to the three levels above. ### Step 1 — Set your catalogue-wide default Open the **catalogue default** section. Pick the care symbols that should apply to most of your products. You can also add a short note in your own words ("our linens are pre-washed and gentle on skin", for example). Save it. That's now the baseline for everything. ### Step 2 — Add rules for groups of products Open the **rules** section and create a new rule. You'll choose: - **Which products it applies to** — for example, products tagged as silk, or products in your "kids" category, or products weighing under a certain amount. - **What care symbols those products should show.** - **An optional note.** You can write as many rules as you like. If a product matches more than one, the most specific rule (the one with the most conditions) wins. ### Step 3 — Override individual products Occasionally you'll have a product that doesn't fit any rule — maybe a vintage jacket that needs specialist dry cleaning. Open the **products** section, find the item, and give it its own care label. That label takes priority over everything else for that product. ### Picking symbols The app uses the international ISO 3758 care symbols — the same little icons sewn into clothing labels worldwide. They're grouped into categories: washing, bleaching, drying, ironing, and professional cleaning. You can browse them by category or search by name. Hover over any symbol to see what it means. ### Adding notes Each label can include a short written note (for example, "we recommend using a colour-catcher sheet"). You only need to write the note once, in whatever language you're working in. The app will automatically translate it for shoppers viewing in another language. If you'd rather write a translation yourself for a specific language, you can — just switch to that language in the admin and type your own version. ### Display settings Below the editor you'll find display settings that change how the public page looks to your shoppers — for example, whether to show short descriptions next to each symbol, or how prominent the brand styling should be. Changes take effect immediately. --- ## What shoppers see Shoppers reach the care page either by scanning a QR code on the product, tapping a link from a product page, or clicking a small care widget embedded elsewhere. ### The full care page This is the main experience. It shows: - A clear heading with the product name. - All the care symbols, grouped by category, each with its name and a short description. - Any note you wrote, translated into their language. - A clean, mobile-friendly layout that works in light or dark mode and adapts to your brand colours. Shoppers can switch between English, German, and French at any time. ### The care widget Sometimes you don't want the full page — you want a small preview that fits inside another page or card. The widget shows: - A small icon and title ("Care Instructions"). - A row of the most important care symbols. - A button that opens the full care page when tapped. The widget is compact, comes in a few sizes, and is perfect for product cards, summary panels, or anywhere you want to hint at the full care information without taking over the screen. --- ## Tips and good habits - **Start broad, refine later.** Set your catalogue-wide default first. Add rules only as you discover groups of products that genuinely need different care. Use product-specific overrides sparingly. - **Keep notes short and useful.** Customers skim. One or two friendly sentences beats a paragraph. - **Trust the auto-translation.** It works well for short care notes. Only write manual translations when you have a specific phrase you want to control. - **Preview before you publish.** After saving any change, view the public page yourself to check it reads the way you want. - **Less is more.** A clear handful of symbols is more useful to a shopper than a wall of every possible icon. --- ## Frequently asked questions **Do I have to set up rules?** No. If you only have a catalogue-wide default, every product will use it. Rules are just a convenience for groups that need something different. **What if a product matches two rules?** The more specific rule wins — that is, the one with more conditions, or the one whose conditions are more narrowly defined. **Can I have different care labels in different languages?** The symbols are universal — they don't change between languages. Only the note text is translated. You can let the app translate automatically, or write your own version for any language. **What if I haven't set anything up yet?** The public care page will gracefully say there are no instructions yet. Once you save your catalogue-wide default, it appears immediately. **Can I have multiple care labels on one product?** *(for example, "shell" and "lining" instructions for a coat)* Not yet — each product currently has one set of care instructions. Multi-part labels are something we may add in the future. --- ## In short 1. Set a sensible catalogue-wide default. 2. Add a few rules for product groups that need different care. 3. Override individual products only when you must. 4. Let shoppers enjoy a clear, multilingual care page — either the full version, or a tidy widget tucked into another part of your site. That's it. Happy laundering. --- # SDK guide: App Objects: Cases, Threads & Records Source: https://docs.smartlinks.app/docs/sdk/guides/app-objects Three generic app-scoped object types with JSONB zones, visibility levels, and flexible schemas. # App Objects: Cases, Threads, and Records This guide covers the three generic app-scoped object types that apps can use as flexible building blocks for different use cases: **Cases**, **Threads**, and **Records**. --- ## Overview SmartLinks provides three generic data models scoped to your app that can be adapted for countless scenarios. Think of them as configurable primitives that you shape to fit your needs: - **Cases** — Track issues, requests, or tasks that need resolution - **Threads** — Manage discussions, comments, or any reply-based content - **Records** — Store structured data with flexible lifecycles Each object type supports: - **JSONB zones** (`data`, `owner`, `admin`) for granular access control - **Visibility levels** (`public`, `owner`, `admin`) for content exposure - **Flexible schemas** — store any JSON in the zone fields - **Admin and public endpoints** for different caller contexts - **Rich querying** with filters, sorting, pagination, and aggregations ```text ┌─────────────────────────────────────────────────────────────────┐ │ Your SmartLinks App │ │ │ │ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ │ │ Cases │ │ Threads │ │ Records │ │ │ │ │ │ │ │ │ │ │ │ • Support │ │ • Comments │ │ • Bookings │ │ │ │ • Warranty │ │ • Q&A │ │ • Licenses │ │ │ │ • Feedback │ │ • Reviews │ │ • Visits │ │ │ │ • RMA │ │ • Forum │ │ • Events │ │ │ └─────────────┘ └─────────────┘ └─────────────┘ │ │ │ │ All scoped to: /collection/:cId/app/:appId │ └─────────────────────────────────────────────────────────────────┘ ``` --- ## The JSONB Zone Model All three object types use a three-tier access model with JSONB fields: | Zone | Visible to | Writable by | Use Case | |---------|-------------------|-------------------|-----------------------------------| | `data` | public, owner, admin | public, owner, admin | Shared public information | | `owner` | owner, admin | owner, admin | User-specific private data | | `admin` | admin | admin | Internal notes, sensitive data | ### How Zones Work Zones are **automatically filtered** based on the caller's role: ```typescript // Public endpoint caller sees: { id: 'case_123', status: 'open', data: { issue: 'Screen cracked', photos: [...] }, // owner and admin zones stripped } // Owner (authenticated contact) sees: { id: 'case_123', status: 'open', data: { issue: 'Screen cracked', photos: [...] }, owner: { shippingAddress: '...', preference: 'email' }, // admin zone stripped } // Admin sees everything: { id: 'case_123', status: 'open', data: { issue: 'Screen cracked', photos: [...] }, owner: { shippingAddress: '...', preference: 'email' }, admin: { internalNotes: 'Escalate to tier 2', cost: 45.00 } } ``` **Key insight:** The server strips zones before returning objects. You don't need to worry about accidentally leaking `admin` data — it's never sent to non-admin callers. ### Zone Writing Rules - **Non-admin callers** attempting to write to the `admin` zone are silently ignored - **Authenticated record owners** can write to `data` and `owner` by default; individual keys can be restricted via the `ownerEdit` app config policy (see [Owner Edit Policy](#owner-edit-policy) below) - **Public callers** can write to `data` and `owner` (if visibility allows) - **Admins** can write to all three zones --- ## Visibility Levels Each object has a `visibility` field that controls who can access it on **public endpoints**: | Visibility | Public Endpoint Behavior | |------------|--------------------------------------------------| | `public` | Anyone can read (even anonymous) | | `owner` | Only the owning contact can read | | `admin` | Never visible on public endpoints (404) | **Admin endpoints** always return all objects regardless of visibility. ### Typical Patterns ```typescript // Public discussion thread await app.threads.create(collectionId, appId, { visibility: 'public', title: 'How do I clean this product?', body: { text: 'Looking for cleaning instructions...' } }); // Private support case await app.cases.create(collectionId, appId, { visibility: 'owner', // Only this contact can see it category: 'warranty', data: { issue: 'Defective unit' }, owner: { serialNumber: 'ABC123' } }); // Admin-only internal record await app.records.create(collectionId, appId, { visibility: 'admin', // Never appears on public endpoints recordType: 'audit_log', admin: { action: 'manual_refund', amount: 50.00 } }, true); // admin = true ``` --- ## Paginated List Responses Every `.list()` call returns a **`PaginatedResponse`** object. The items are in the `data` array and all page-level metadata lives in a nested `pagination` object: ```json { "data": [ { "id": "7ac44316-c227-4c39-bf99-a287bc08c6f5", "collectionId": "veho-demo", "appId": "knowledgeBase", "visibility": "public", "recordType": "article", "status": "published", "createdAt": "2026-02-25T22:13:14.310Z", "updatedAt": "2026-02-25T22:47:36.712Z", "data": { "title": "Getting Started", "slug": "getting-started", "body": "..." } } ], "pagination": { "total": 42, "limit": 10, "offset": 0, "hasMore": true } } ``` ### Pagination fields | Field | Type | Description | |---|---|---| | `data` | `T[]` | The page of items returned | | `pagination.total` | `number` | Total number of matching records across **all** pages | | `pagination.limit` | `number` | The `limit` that was applied to this request (default `50`, max `500`) | | `pagination.offset` | `number` | The `offset` that was applied to this request | | `pagination.hasMore` | `boolean` | `true` when more pages exist — use this instead of computing `offset + limit < total` yourself | > **Note:** The items are always in `response.data`, **not** at the top level. A common mistake is reading `response.total` — the correct path is `response.pagination.total`. ### Fetching all pages ```typescript import { app, PaginatedResponse, AppRecord } from '@proveanything/smartlinks'; async function fetchAllRecords(collectionId: string, appId: string) { const results: AppRecord[] = []; let offset = 0; const limit = 100; while (true) { const page: PaginatedResponse = await app.records.list( collectionId, appId, { limit, offset, sort: 'createdAt:desc' } ); results.push(...page.data); if (!page.pagination.hasMore) break; // no more pages offset += limit; } console.log(`Fetched ${results.length} of ${/* saved from first page */ 0} total`); return results; } ``` ### Reading the count and checking for more ```typescript const page = await app.cases.list(collectionId, appId, { status: 'open', limit: 10 }); console.log(page.data); // array of AppCase objects console.log(page.pagination.total); // e.g. 142 — total open cases console.log(page.pagination.hasMore); // true / false console.log(page.pagination.offset); // current page start console.log(page.pagination.limit); // items per page ``` --- ## Cases **Cases** represent trackable issues, requests, or tasks that move through states and require resolution. ### When to Use Cases - **Customer support tickets** — track issues from creation to resolution - **Warranty claims** — manage claims with status, priority, and assignment - **Feature requests** — collect and triage user feedback - **RMA (Return Merchandise Authorization)** — handle product returns - **Bug reports** — track defects from user submissions - **Service requests** — manage appointments, repairs, installations ### Key Features - **Status lifecycle** — `'open'` → `'in-progress'` → `'resolved'` → `'closed'` (or custom statuses) - **Priority levels** — numerical priority for sorting/escalation - **Categories** — group cases by type (warranty, bug, feature, etc.) - **Assignment** — `assignedTo` field for routing to team members - **History tracking** — append timestamped entries to `admin.history` or `owner.history` - **Closing metrics** — track `closedAt` to measure resolution time ### Example: Warranty Claims ```typescript import { app } from '@proveanything/smartlinks'; // Customer submits a warranty claim (public endpoint) const claim = await app.cases.create(collectionId, appId, { visibility: 'owner', category: 'warranty', status: 'open', priority: 2, productId: product.id, proofId: proof.id, contactId: user.contactId, data: { issue: 'Screen flickering after 3 months', photos: ['https://...', 'https://...'] }, owner: { purchaseDate: '2025-11-15', serialNumber: 'SN-7738291', preferredContact: 'email' } }); // Admin reviews and assigns (admin endpoint) await app.cases.update(collectionId, appId, claim.id, { assignedTo: 'user_jane_support', priority: 3, // escalate admin: { internalNotes: 'Likely hardware defect, approve replacement' } }, true); // admin = true // Admin appends to history await app.cases.appendHistory(collectionId, appId, claim.id, { entry: { action: 'approved_replacement', agent: 'Jane', tracking: 'UPS-123456789' }, historyTarget: 'owner', // visible to customer status: 'resolved' }); // Get case summary stats (admin) const summary = await app.cases.summary(collectionId, appId, { period: { from: '2026-01-01', to: '2026-02-28' } }); // Returns: { total: 142, byStatus: { open: 12, resolved: 130 }, ... } ``` ### Use Case: Support Dashboard Build a live support dashboard showing open cases by priority: ```typescript const openCases = await app.cases.list(collectionId, appId, { status: 'open', sort: 'priority:desc', limit: 50 }, true); // Aggregate by category const stats = await app.cases.aggregate(collectionId, appId, { filters: { status: 'open' }, groupBy: ['category', 'priority'], metrics: ['count'] }, true); // Time series: cases created per week const trend = await app.cases.aggregate(collectionId, appId, { timeSeriesField: 'created_at', timeSeriesInterval: 'week', metrics: ['count'] }, true); ``` --- ## Threads **Threads** represent discussions, comments, or any content that accumulates replies over time. ### When to Use Threads - **Product Q&A** — questions and answers about products - **Community forums** — discussions grouped by topic - **Comments** — on products, proofs, or other resources - **Review discussions** — follow-up questions on reviews - **Feedback threads** — ongoing conversations about features - **Support chat** — lightweight message threads ### Key Features - **Reply tracking** — `replies` array with timestamped entries - **Reply count** — auto-incremented `replyCount` and `lastReplyAt` - **Slugs** — optional URL-friendly slug for pretty URLs - **Tags** — JSONB array of tags for categorization - **Parent linking** — `parentType` + `parentId` to attach to products, proofs, etc. - **Author metadata** — track `authorId` and `authorType` ### Example: Product Q&A ```typescript import { app } from '@proveanything/smartlinks'; // Customer asks a question (public endpoint) const question = await app.threads.create(collectionId, appId, { visibility: 'public', slug: 'how-to-clean-leather', title: 'How do I clean leather without damaging it?', status: 'open', authorId: user.contactId, authorType: 'customer', productId: product.id, body: { text: 'I spilled coffee on my leather bag. What cleaner is safe to use?' }, tags: ['cleaning', 'leather', 'care'] }); // Another customer replies await app.threads.reply(collectionId, appId, question.id, { authorId: otherUser.contactId, authorType: 'customer', text: 'I use a mild soap and water solution. Works great!' }); // Admin (brand expert) replies await app.threads.reply(collectionId, appId, question.id, { authorId: 'user_expert_sarah', authorType: 'brand_expert', text: 'Our official leather care kit is perfect for this. Avoid harsh chemicals.', productLink: 'prod_leather_care_kit' }, true); // admin endpoint // Admin marks as resolved await app.threads.update(collectionId, appId, question.id, { status: 'resolved' }, true); ``` ### Use Case: Forum-Style Discussions List recent discussions with reply counts: ```typescript // Get active threads const activeThreads = await app.threads.list(collectionId, appId, { status: 'open', sort: 'lastReplyAt:desc', limit: 20 }); // Filter by tag const cleaningThreads = await app.threads.list(collectionId, appId, { tag: 'cleaning' }); // Aggregate: most active discussion topics const topicStats = await app.threads.aggregate(collectionId, appId, { groupBy: ['status'], metrics: ['count', 'reply_count'] }); ``` ### Use Case: Product Comments Attach comments to a specific product: ```typescript // Create a comment thread for a product await app.threads.create(collectionId, appId, { visibility: 'public', parentType: 'product', parentId: product.id, authorId: user.contactId, body: { text: 'Love this product! Best purchase ever.' }, tags: ['positive'] }); // List all comments for a product const productComments = await app.threads.list(collectionId, appId, { parentType: 'product', parentId: product.id, sort: 'createdAt:desc' }); ``` --- ## Records **Records** are the most flexible object type — use them for structured data with time-based lifecycles, hierarchies, or custom schemas. Records also support **structured targeting** — each record can declare which products, variants, batches, or proofs it applies to (via anchor fields), or which product attributes match (via `facetRule`), and the platform can match records against a runtime context. ### When to Use Records - **Bookings/Reservations** — track appointments with start/end times - **Licenses** — manage software licenses with expiration - **Subscriptions** — track subscription status and renewal - **Certifications** — store certifications with expiry dates - **Events** — track event registrations and attendance - **Usage logs** — record product usage metrics - **Audit trails** — immutable logs of actions - **Loyalty points** — track points earned/redeemed - **Per-product / per-facet configuration** — scoped data that varies by product axis (see [app-records-pattern.md](app-records-pattern.md)) ### Key Features - **Record types** — `recordType` field for categorization (required) - **Time windows** — `startsAt` and `expiresAt` for time-based data - **Anchor fields** — `productId`, `variantId`, `batchId`, `proofId` restrict which context the record applies to - **Facet rules** — `facetRule` matches records to products based on attribute values - **Specificity scoring** — `specificity` enables "best match" resolution across multiple targeted records - **Parent linking** — attach to products, proofs, contacts, etc. - **Author tracking** — `authorId` + `authorType` - **Status lifecycle** — custom statuses (default `'active'`) - **References** — optional `ref` field for external IDs; auto-derived from anchor fields if omitted ### Targeted Records Records use flat anchor fields to declare what context they apply to. A record with no anchor fields is universal — it applies everywhere. Populated anchor fields restrict the context to a specific product, variant, batch, or proof. ```typescript import { app } from '@proveanything/smartlinks'; // A nutrition record anchored to a specific product await app.records.create(collectionId, appId, { recordType: 'nutrition', productId: 'prod_abc', data: { calories: 250, protein: 12.5 }, }, true); // A nutrition record for a specific variant, overriding the product-level record await app.records.create(collectionId, appId, { recordType: 'nutrition', productId: 'prod_abc', variantId: 'var_500ml', data: { calories: 260, protein: 12.5 }, }, true); // A record matching products by facet rule (applies to all products with tier=gold) await app.records.create(collectionId, appId, { recordType: 'loyalty_promo', facetRule: { all: [{ facetKey: 'tier', anyOf: ['gold', 'platinum'] }], }, data: { discountPercent: 15 }, }, true); ``` The `ref` field is derived automatically from anchor fields when omitted: ``` productId: 'prod_abc' → ref: 'product:prod_abc' productId: 'prod_abc', variantId: 'var_x' → ref: 'product:prod_abc/variant:var_x' (no anchor fields) → ref: '' (collection-level catch-all) facetRule: { ... } → ref: 'rule:' ``` #### Specificity scores When multiple scoped records match a context, they are ordered by `specificity`. Higher = more specific: | Field / element | Points | |-----------------|--------| | `proofId` | +1000 | | `batchId` | +500 | | `variantId` | +250 | | `productId` | +100 | | Per `facetRule` clause | +50 | | Per `anyOf` value | +1 | | No anchors / no rule | 0 | ### Resolution order When the public side of a records-based app needs "the data that applies to this product context", the platform walks a canonical chain from most-specific to least-specific: ``` proof → batch → variant → product → rule(*) → facet(*) → collection ``` - `rule(*)` — `facetRule`-targeted records are scored by **specificity** (number of clauses + number of constrained values). The most specific rule wins at its tier. - `facet(*)` — legacy single-facet anchors, walked alphabetically. Prefer `facetRule` for new work. - `collection` — the top of the chain and the catch-all for any record with no anchor fields. **There is no `'global'` tier above collection.** For a **singleton** record type (one answer wins), use `useResolvedRecord` — it performs this walk server-side and returns the first match plus a `matchedAt` tag. For a **collection** record type (every match is shown), use `useCollectedRecords`. See [app-records-pattern.md §2](app-records-pattern.md#2-resolution-order-one-canonical-chain) for the full guide. ### Singleton Cardinality By default, `create` always inserts a new row — calling it twice produces two records with identical anchor fields. **Singleton cardinality** changes that: pass `singletonPer` on creation and the server will **upsert** instead, ensuring at most one record of a given `recordType` exists per scope boundary. ```typescript // Ensure only one active registration record per product per contact await app.records.create(collectionId, appId, { recordType: 'product_registration', visibility: 'owner', contactId: user.contactId, productId: product.id, singletonPer: 'product', // one per (appId + recordType + contactId + productId) data: { registeredAt: new Date().toISOString() }, }); ``` `singletonPer` values and the scope they enforce: | Value | De-duplicates across | |-------|---------------------| | `'collection'` | entire app (one record of this type per app) | | `'product'` | `productId` | | `'variant'` | `variantId` | | `'batch'` | `batchId` | | `'proof'` | `proofId` | The server assigns a **`singletonKey`** to each record that is governed by this rule — an opaque, stable string that acts as the upsert key. If a record with the same key already exists the server updates it in place (clearing `deletedAt` if it was soft-deleted) and returns the existing `id`. `singletonKey` is read-only and exposed on `AppRecord` for debugging but has no meaning to clients. **When to use `singletonPer`:** - One loyalty card per contact per product - One registration per proof scan - One active subscription record per variant - Any "find-or-create" pattern where calling `create` twice should be idempotent ### Matching Records Against a Context Use `app.records.match()` to find records whose scope is satisfied by a runtime target: ```typescript // Find all nutrition records that apply for this product + facet context const { data } = await app.records.match(collectionId, appId, { target: { productId: 'prod_abc', facets: { tier: ['gold'] }, }, recordType: 'nutrition', }, true); // data is ordered by specificity descending — most specific first // each entry carries a `matchedAt` field indicating which dimension matched // Use strategy: 'best' to get only the single winner per recordType const { data: best } = await app.records.match(collectionId, appId, { target: { productId: 'prod_abc', variantId: 'var_500ml' }, strategy: 'best', }, true); // best[0] → the highest-specificity record for this context ``` Facet matching rules: - Multiple `facets` clauses are **ANDed** — all must be satisfied - Values within a single clause are **ORed** — any matching value satisfies it - A record with no facet clauses is satisfied by any target #### `matchedAt` — match attribution Every record in the response includes a `matchedAt` field indicating **which matching dimension caused the match**. Use it to render attribution labels: ```typescript const { data } = await app.records.match(collectionId, appId, { target, recordType: 'nutrition' }, true); for (const entry of data) { switch (entry.matchedAt) { case 'rule': /* "Matches rule" */ break; case 'proof': /* "Scan-specific" */ break; case 'batch': /* "Batch-specific" */ break; case 'variant': /* "Size-specific" */ break; case 'product': /* "Inherited from product" */ break; case 'facet': /* "Tier-specific" */ break; case 'collection': /* "Collection default" */ break; } } ``` Precedence follows: `proof > batch > variant > product > rule > facet > collection`. There is no scope above `collection` — a record with no anchor fields is a collection-level catch-all. #### React — `useResolvedRecord` For React consumers, the `useResolvedRecord` hook in `@proveanything/smartlinks-utils-ui` wraps `records.match()` and returns the best-matching record with loading and error states. The raw `records.match()` API exists for non-React consumers and custom resolution logic. ### Facet-Rule Records A record can declare a **multi-clause boolean rule** (`facetRule`) describing which products it applies to, instead of a single `scope.facets` entry. The rule is AND across facet keys, OR within values of each key: ```typescript // Create a record that matches all Samsung TVs and laptops await app.records.create(collectionId, appId, { recordType: 'warranty', facetRule: { all: [ { facetKey: 'brand', anyOf: ['samsung'] }, { facetKey: 'type', anyOf: ['tv', 'laptop'] }, ], }, data: { warrantyYears: 2 }, }, true); ``` `facetRule` and anchor fields (`productId`, `variantId`, etc.) are mutually exclusive. A record uses either anchor-based targeting or a facetRule, never both. The server assigns `ref: 'rule:'` automatically. Specificity for rule records: `Σ (50 + clause.anyOf.length)` across all clauses. A 2-clause rule with 1 value each scores `(50+1)+(50+1) = 102`, which ranks above a plain product-scoped record (100) in `resolveAll()` results. Use `records.previewRule()` to see which products a rule would match before creating it: ```typescript const { matchingProducts, total } = await app.records.previewRule(collectionId, appId, { facetRule: { all: [{ facetKey: 'brand', anyOf: ['samsung'] }], }, }); // total: 42, matchingProducts: [{ productId: 'prod_001', facets: {...} }, ...] ``` ### Resolve All Use `app.records.resolveAll()` to fetch **every applicable record for a product context** in one request—across all tiers (proof, batch, variant, product, rule, facet, collection defaults), deduplicated and sorted by specificity: ```typescript // All records that apply to this product context (admin) const { records, total, truncated } = await app.records.resolveAll(collectionId, appId, { context: { productId: 'prod_001', facets: { brand: 'samsung', type: 'tv' }, }, recordType: 'warranty', }, true); for (const entry of records) { console.log(entry.matchedAt, entry.specificity, entry.record.id); if (entry.matchedAt === 'rule') { console.log('rule fired:', entry.matchedRule, 'clauses:', entry.matchedClauseCount); } } // Public endpoint — visibility-filtered (admin records excluded) const { records: publicRecords } = await app.records.resolveAll(collectionId, appId, { context: { productId: 'prod_001', facets: { brand: 'samsung' } }, }, false); // Filter to specific tiers const { records: ruleRecords } = await app.records.resolveAll(collectionId, appId, { context: { productId: 'prod_001', facets: { brand: 'samsung', type: 'tv' } }, tiers: ['product', 'rule', 'collection'], }, true); ``` `truncated: true` means the result hit the safety cap (default 500). Raise it with `limit` (max 5000). ### Upsert Create-or-update a record by `ref` in a single call: ```typescript const { created } = await app.records.upsert(collectionId, appId, { ref: 'product:prod_abc', recordType: 'nutrition', productId: 'prod_abc', data: { calories: 250, protein: 12.5 }, }); // created: true if new, false if updated ``` ### Bulk Operations Upsert or delete large sets of records efficiently: ```typescript // Bulk upsert up to 500 records in one transaction const result = await app.records.bulkUpsert(collectionId, appId, [ { ref: 'product:prod_abc', recordType: 'nutrition', productId: 'prod_abc', data: { calories: 250 } }, { ref: 'product:prod_xyz', recordType: 'nutrition', productId: 'prod_xyz', data: { calories: 180 } }, ]); // result: { saved: 2, failed: 0, results: [...] } // Bulk delete by explicit refs await app.records.bulkDelete(collectionId, appId, { refs: ['product:prod_abc', 'product:prod_xyz'], recordType: 'nutrition', }); // Bulk delete by anchor (all records under a product) await app.records.bulkDelete(collectionId, appId, { scope: { productId: 'prod_abc' }, }); ``` ### Soft-Delete Semantics `delete` and `bulkDelete` **soft-delete** records: the row is retained with a non-null `deletedAt` and excluded from all queries by default. Records are **recoverable indefinitely** — there is no expiry on `deletedAt`. ```typescript // Single-record restore const restored = await app.records.restore(collectionId, appId, recordId); // List including deleted records (admin only) const all = await app.records.list(collectionId, appId, { recordType: 'nutrition', includeDeleted: true, }, true); // all.data includes records with non-null deletedAt ``` `bulkDelete` is fully reversible: rows survive with their IDs intact. Restore individually via `restore`, or re-write via `bulkUpsert` (which will find the existing row by `ref` and update it, clearing `deletedAt` in the process). ### Text Search The `q` parameter on `GET /records` performs a **case-insensitive substring match** (`ILIKE`) on `data->>'label'`. It works on both admin and public list endpoints today: ```typescript const results = await app.records.list(collectionId, appId, { recordType: 'product', q: 'premium', }, true); // returns records where data.label contains 'premium' (case-insensitive) ``` > `q` is not a full-text index and does not return ranked results. For ranked relevance search over large corpora, use the Elasticsearch integration. ### External ID / ETL Workflow `customId` and `sourceSystem` provide a stable external key pair for loading records from external systems (CMS, ERP, PIM, etc.): - Both fields are **indexed** via a composite index on `(sourceSystem, customIdNormalized)`. - `customId` is **filterable** on `GET /records?customId=x&sourceSystem=y`. - The pair is **not unique** — the same external ID can exist across different `recordType` values by design (a CMS slug can appear in both a `content` and a `nutrition` record). - `upsert` currently keys on `ref`, not `customId`. The recommended ETL pattern is to derive a stable `ref` from the external ID and pass `customId` alongside: ```typescript // Idiomatic ETL upsert: ref is derived from the external key, customId carries it too await app.records.upsert(collectionId, appId, { ref: `cms:${slug}`, // stable find-or-create key customId: slug, sourceSystem: 'contentful', recordType: 'content_page', productId, data: { title, body }, }); // upsert finds-or-creates by ref deterministically, // customId is stored for later reverse-lookup via ?customId=&sourceSystem= ``` ### Counts by Record Type `aggregate()` returns counts grouped by `record_type` in a single round-trip — no separate endpoint needed: ```typescript const stats = await app.records.aggregate(collectionId, appId, { groupBy: ['record_type'], metrics: ['count'], // Optionally narrow the corpus: filters: { status: 'active' }, }, true); // stats.groups → [{ record_type: 'nutrition', count: 42 }, { record_type: 'loyalty_promo', count: 7 }, ...] // Ordered by count descending ``` You can also combine with other filters: ```typescript // Counts per type for a specific product await app.records.aggregate(collectionId, appId, { groupBy: ['record_type'], metrics: ['count'], filters: { product_id: 'prod_abc' }, }, true); ``` ### Canonical Ref Format The `ref` field is **server-derived** when anchor fields are provided and `ref` is omitted. Clients should never construct ref strings manually. The authoritative grammar is slash-joined: ``` [product:{productId}/][variant:{variantId}/][batch:{batchId}/][proof:{proofId}] ``` Examples: | Anchor fields | Derived ref | |---------------|-------------| | `productId: 'prod_abc'` | `product:prod_abc` | | `productId: 'prod_abc', variantId: 'var_500ml'` | `product:prod_abc/variant:var_500ml` | | `batchId: 'batch_q1'` | `batch:batch_q1` | | `facetRule: { ... }` | `rule:` | | *(no anchor fields)* | `''` (collection-level catch-all) | `parseRef` / `buildRef` in `data/refs.ts` should be used for **display and URL round-tripping only**, never as upsert keys. For ETL use cases, set an explicit `ref` using a stable external key (see [External ID / ETL Workflow](#external-id--etl-workflow)). `startsAt` and `expiresAt` control record active windows. The list and match endpoints respect scheduling by default (only returning currently-active records). Override with: ```typescript // Include future records and expired records await app.records.list(collectionId, appId, { includeScheduled: true, includeExpired: true, }, true); // Preview what records will be active at a future point in time await app.records.match(collectionId, appId, { target: { productId: 'prod_abc' }, at: '2026-06-01T00:00:00Z', }, true); ``` ### Example: Product Registration ```typescript import { app } from '@proveanything/smartlinks'; // Customer registers a product const registration = await app.records.create(collectionId, appId, { recordType: 'product_registration', visibility: 'owner', status: 'active', productId: product.id, proofId: proof.id, contactId: user.contactId, authorId: user.contactId, authorType: 'customer', startsAt: new Date().toISOString(), expiresAt: new Date(Date.now() + 365*24*60*60*1000).toISOString(), // 1 year warranty data: { registrationNumber: 'REG-2026-1234', purchaseDate: '2026-02-15', retailer: 'Best Electronics' }, owner: { serialNumber: 'SN-9922736', installDate: '2026-02-20', location: 'Home office' } }); // List active registrations for a customer const activeRegistrations = await app.records.list(collectionId, appId, { contactId: user.contactId, recordType: 'product_registration', status: 'active' }); // Find expiring registrations (admin) const expiringSoon = await app.records.list(collectionId, appId, { recordType: 'product_registration', expiresAt: `lte:${new Date(Date.now() + 30*24*60*60*1000).toISOString()}` // next 30 days }, true); ``` ### Example: Appointment Booking ```typescript // Customer books a service appointment const booking = await app.records.create(collectionId, appId, { recordType: 'service_appointment', visibility: 'owner', contactId: user.contactId, startsAt: '2026-03-15T10:00:00Z', expiresAt: '2026-03-15T11:00:00Z', // 1-hour appointment data: { serviceType: 'installation', location: 'Customer site', technician: null // assigned later }, owner: { address: '123 Main St', phone: '555-1234', notes: 'Call before arrival' } }); // Admin assigns technician await app.records.update(collectionId, appId, booking.id, { data: { serviceType: 'installation', location: 'Customer site', technician: 'tech_john' }, admin: { cost: 150.00, travelTime: 30 } }, true); // List today's appointments const today = new Date().toISOString().split('T')[0]; const todaysAppointments = await app.records.list(collectionId, appId, { recordType: 'service_appointment', startsAt: `gte:${today}T00:00:00Z`, sort: 'startsAt:asc' }, true); ``` ### Example: Usage Tracking ```typescript // Log product usage (could be triggered by IoT device) await app.records.create(collectionId, appId, { recordType: 'usage_log', visibility: 'admin', productId: product.id, proofId: proof.id, startsAt: new Date().toISOString(), data: { metric: 'power_on', duration: 3600, // seconds location: 'geo:37.7749,-122.4194' } }, true); // Aggregate usage metrics const usageStats = await app.records.aggregate(collectionId, appId, { filters: { record_type: 'usage_log', created_at: { gte: '2026-02-01', lte: '2026-02-28' } }, groupBy: ['product_id'], metrics: ['count'] }, true); ``` --- ## Public Create Policies Control who can create objects on **public endpoints** by setting a `publicCreate` policy on your app's config document (identified by `appId` within your collection). Set the policy via: ``` POST /api/v1/admin/collection/:collectionId/apps/:appId ``` The server reads this document at request time — no cache invalidation or service restart is required. ### Policy Structure Each object type (`cases`, `threads`, `records`) has **independent branches** for anonymous and authenticated callers. Each branch carries its own `allow` flag, optional field overrides (`enforce`), and — for records — optional edit-token config (`edit`). ```typescript interface PublicCreatePolicy { cases?: PublicCreateObjectRule threads?: PublicCreateObjectRule records?: PublicCreateObjectRule } interface PublicCreateObjectRule { anonymous?: PublicCreateBranch authenticated?: PublicCreateBranch } interface PublicCreateBranch { /** Whether creation is permitted for this caller class */ allow: boolean /** * Hard overrides merged over the caller's body before writing. * Lock down visibility and status regardless of what clients send. */ enforce?: { visibility?: 'public' | 'owner' | 'admin' status?: string } /** * Anonymous edit-token config — records only. * See "Anonymous Edit Tokens" section below. */ edit?: { editToken: boolean windowMinutes?: number // omit for no expiry } } ``` #### Visibility enforcement guard-rails The server silently corrects misconfigured visibility values: | Caller type | `enforce.visibility` supplied | Server overrides to | |-----------------|-------------------------------|---------------------| | `anonymous` | `'owner'` | `'admin'` | | `authenticated` | `'public'` | `'owner'` | These guards exist because anonymous callers have no identity to own a record, and `'public'` visibility on authenticated-only objects would be a misconfiguration. ### Example Policies **Support tickets from anyone:** ```json { "publicCreate": { "cases": { "anonymous": { "allow": true, "enforce": { "visibility": "public", "status": "open" } }, "authenticated": { "allow": true, "enforce": { "visibility": "owner", "status": "open" } } } } } ``` **Public Q&A threads, authenticated only:** ```json { "publicCreate": { "threads": { "anonymous": { "allow": false }, "authenticated": { "allow": true, "enforce": { "visibility": "public", "status": "open" } } } } } ``` **Anonymous record creation with edit token (30-minute window):** ```json { "publicCreate": { "records": { "anonymous": { "allow": true, "enforce": { "visibility": "public", "status": "pending" }, "edit": { "editToken": true, "windowMinutes": 30 } }, "authenticated": { "allow": true, "enforce": { "visibility": "owner", "status": "pending" } } } } } ``` **No public record creation:** ```json { "publicCreate": { "records": { "anonymous": { "allow": false }, "authenticated": { "allow": false } } } } ``` The `enforce` values are **merged over** the caller's request body, so you can lock down fields like `visibility` and `status` regardless of what clients send. --- ## Owner Edit Policy Gives per-zone, field-level control over what an **authenticated record owner** can update via `PATCH /api/v1/public/collection/:collectionId/app/:appId/records/:recordId`. Set the policy in the same app config document used for `publicCreate` (stored at `sites/{collectionId}/apps/{appId}`): ```json { "ownerEdit": { "records": { "data": { "allow": ["paypalEmail"] }, "owner": { "allow": ["paypalEmail", "paypalEmailUpdatedAt"] } } } } ``` ### Zone visibility and write access | Zone | Who can read | Who can write (owner) | |---------|------------------------|----------------------------------------------------------| | `data` | public | Allow-listed keys only (if policy set); all keys if not | | `owner` | owner + admin | Allow-listed keys only (if policy set); all keys if not | | `admin` | admin | Never — admin zone is always immutable to owners | ### Allow-list semantics | Config | Behaviour | |----------------------------|-------------------------------------------------------------------------------| | No `ownerEdit` key | Default-allow — both zones fully writable (no change to existing behaviour) | | `allow` array with keys | Only the listed keys are accepted from the PATCH body; the rest are silently ignored and their existing values preserved | | `allow: []` (empty array) | Zone is effectively read-only for the owner | Accepted keys are **merged** onto the existing zone blob — you do not need to re-send unchanged values. ### Example: commission record with protected fields An app that lets owners update their payout email but not their commission total: ```json { "ownerEdit": { "records": { "owner": { "allow": ["paypalEmail", "paypalEmailUpdatedAt"] } } } } ``` A PATCH body of `{ "owner": { "paypalEmail": "x@y.com", "totalCommission": 99 } }` will update `paypalEmail` only. `totalCommission` is silently ignored and its existing value is preserved. > **App design note:** If your app creates records with sensitive fields that owners should never modify (e.g. computed totals, server-assigned fields), add an `ownerEdit` policy from the start. It is significantly easier to relax restrictions later than to tighten them after data has been mutated. --- ## Anonymous Edit Tokens Enables an anonymous caller to amend a record they just created — without authentication — by presenting a short-lived secret token. Designed for flows where a client needs to make a follow-up update before a server-side process locks the record. Common examples: payment + confirmation, multi-step forms, IoT device registration. ### How It Works ``` 1. Configure — set publicCreate.records.anonymous.edit.editToken: true in app config 2. Create — anonymous POST /records returns { ...record, editToken: "3f8a2c1e..." } Token is stored in record's admin zone; never visible again 3. Amend — PATCH /records/:recordId with X-Edit-Token header Only the data zone may be modified 4. Expiry — if windowMinutes is set, token is rejected after that many minutes ``` ### SDK Usage ```typescript import { app } from '@proveanything/smartlinks'; // Step 1: Create the record (anonymous caller — no auth token) const response = await app.records.create(collectionId, appId, { recordType: 'payment', visibility: 'public', data: { amount: 9900, currency: 'USD' }, }) // editToken is present only when the policy has editToken: true const { editToken } = response // ⚠️ store immediately — returned once only // Step 2: After external confirmation (e.g. payment gateway callback) const updated = await app.records.updateWithToken( collectionId, appId, response.id, { amount: 9900, currency: 'USD', transactionId: 'txn_abc123' }, editToken, ) ``` `app.records.updateWithToken()` sends the token as the `X-Edit-Token` request header on the public PATCH endpoint — no auth token needed. ### Creation Response Shape ```typescript interface CreateRecordResponse extends AppRecord { /** * Present only on anonymous creation when editToken policy is enabled. * Returned ONCE — store it client-side immediately. */ editToken?: string } ``` Example creation response: ```json { "id": "a1b2c3d4-...", "recordType": "payment", "status": "pending", "visibility": "public", "data": { "amount": 9900, "currency": "USD" }, "createdAt": "2026-04-16T12:00:00.000Z", "editToken": "3f8a2c1e..." } ``` ### Amendment Scope Anonymous token updates may only modify the **`data` zone**. The following are immutable via this path: - `owner`, `admin` zones - `status`, `visibility` - All indexed fields (`recordType`, `ref`, `startsAt`, `expiresAt`, etc.) ### Error Codes | HTTP | `errorCode` | Meaning | |------|------------------------|--------------------------------------------------| | 401 | `UNAUTHORIZED` | No auth token and no `X-Edit-Token` header | | 403 | `FORBIDDEN` | `editToken` policy not enabled for this app | | 403 | `FORBIDDEN` | Token does not match | | 403 | `EDIT_WINDOW_EXPIRED` | `windowMinutes` elapsed since record creation | | 404 | `NOT_FOUND` | Record does not exist | ### Security Notes - The token is stored in `admin.editToken` and is **always stripped** from public and owner responses — it cannot be read back after creation. - Token comparison uses `crypto.timingSafeEqual` to prevent timing-based oracle attacks. - The token is a 32-byte (`crypto.randomBytes(32)`) hex string — 256 bits of entropy. - For sensitive flows, combine `windowMinutes` with a server-side process that removes or overwrites the token once the record is confirmed. --- ## Aggregations and Analytics All three object types support powerful aggregation queries for dashboards and reports. ### Aggregation Capabilities ```typescript interface AggregateRequest { filters?: { status?: string category?: string // cases only record_type?: string // records only product_id?: string created_at?: { gte?: string; lte?: string } closed_at?: '__notnull__' | { gte?: string; lte?: string } // cases expires_at?: { lte?: string } // records } groupBy?: string[] // dimension breakdown metrics?: string[] // calculated values timeSeriesField?: string timeSeriesInterval?: 'hour' | 'day' | 'week' | 'month' | 'quarter' | 'year' } ``` ### Cases Aggregations **Group by dimensions:** `status`, `priority`, `category`, `assigned_to`, `product_id`, `contact_id` **Metrics:** `count`, `avg_close_time`, `p50_close_time`, `p95_close_time` ```typescript // Average resolution time by category const metrics = await app.cases.aggregate(collectionId, appId, { filters: { closed_at: '__notnull__' }, groupBy: ['category'], metrics: ['count', 'avg_close_time', 'p95_close_time'] }, true); // Result: // { // groups: [ // { category: 'warranty', count: 45, avg_close_time_seconds: 7200, p95_close_time_seconds: 14400 }, // { category: 'support', count: 89, avg_close_time_seconds: 3600, p95_close_time_seconds: 10800 } // ] // } ``` ### Threads Aggregations **Group by dimensions:** `status`, `author_type`, `product_id`, `visibility`, `contact_id` **Metrics:** `count`, `reply_count` ```typescript // Most active discussion authors const authorStats = await app.threads.aggregate(collectionId, appId, { groupBy: ['author_type'], metrics: ['count', 'reply_count'] }); ``` ### Records Aggregations **Group by dimensions:** `status`, `record_type`, `product_id`, `author_type`, `visibility`, `contact_id` **Metrics:** `count` ```typescript // Bookings by status const bookingStats = await app.records.aggregate(collectionId, appId, { filters: { record_type: 'service_appointment' }, groupBy: ['status'], metrics: ['count'] }, true); ``` ### Time Series Generate time-based charts: ```typescript // Cases created per week const casesTrend = await app.cases.aggregate(collectionId, appId, { timeSeriesField: 'created_at', timeSeriesInterval: 'week', metrics: ['count'] }, true); // Result: // { // timeSeries: [ // { bucket: '2026-W07', count: 23 }, // { bucket: '2026-W08', count: 31 }, // { bucket: '2026-W09', count: 28 } // ] // } ``` --- ## Common Patterns ### Pattern: Related Data Cases have a built-in `related()` endpoint to fetch associated threads and records: ```typescript // Get all related content for a case const related = await app.cases.related(collectionId, appId, caseId); // Returns: { threads: [...], records: [...] } ``` For threads and records, use parent linking: ```typescript // Create a thread about a case await app.threads.create(collectionId, appId, { parentType: 'case', parentId: caseId, body: { text: 'Follow-up discussion about this case' } }); // List all threads for a case const caseThreads = await app.threads.list(collectionId, appId, { parentType: 'case', parentId: caseId }); ``` ### Pattern: Hierarchical Records Use `parentType` and `parentId` to build hierarchies: ```typescript // Parent record: subscription const subscription = await app.records.create(collectionId, appId, { recordType: 'subscription', data: { plan: 'premium', billingCycle: 'monthly' } }); // Child records: invoices await app.records.create(collectionId, appId, { recordType: 'invoice', parentType: 'subscription', parentId: subscription.id, data: { amount: 29.99, period: '2026-02' } }); // List all invoices for a subscription const invoices = await app.records.list(collectionId, appId, { recordType: 'invoice', parentType: 'subscription', parentId: subscription.id }); ``` ### Pattern: Audit Trails Use admin-only records to log changes: ```typescript async function auditLog(action: string, details: any) { await app.records.create(collectionId, appId, { recordType: 'audit_log', visibility: 'admin', authorId: currentUser.id, authorType: 'admin', data: { action, timestamp: new Date().toISOString(), ...details } }, true); } // Usage await auditLog('case_reassigned', { caseId: 'case_123', from: 'user_jane', to: 'user_bob' }); ``` ### Pattern: Notifications Combine with the realtime API to notify users of changes: ```typescript import { app, realtime } from '@proveanything/smartlinks'; // When a case is updated await app.cases.update(collectionId, appId, caseId, { status: 'resolved' }, true); // Notify the contact await realtime.publish(collectionId, `contact:${contactId}`, { type: 'case_resolved', caseId, message: 'Your support case has been resolved' }); ``` --- ## Best Practices ### Use the Right Object Type | Need | Use | |------|-----| | Track something that needs resolution | **Cases** | | Build a discussion or comment system | **Threads** | | Store time-sensitive or hierarchical data | **Records** | ### Zone Allocation Strategy - **`data`** — Put information that's safe for anyone to see (even if `visibility` is `owner`) - **`owner`** — Store user-specific preferences, addresses, contact info - **`admin`** — Keep internal notes, costs, sensitive metadata ### Visibility Defaults - **User-facing content** → `visibility: 'public'` (Q&A, reviews, forums) - **Private user data** → `visibility: 'owner'` (support cases, bookings) - **Internal data** → `visibility: 'admin'` (audit logs, analytics) ### Indexing and Performance For high-volume queries, consider: - Filter by `status`, `recordType`, or `category` to reduce result sets - Use `limit` and `offset` for pagination (max 500 per page) - Use aggregations instead of fetching all records and counting client-side - Index commonly filtered fields if you add custom indexes ### Status Conventions While statuses are free-form strings, consider standard conventions: **Cases:** `open`, `in_progress`, `waiting_customer`, `resolved`, `closed` **Threads:** `open`, `closed`, `locked`, `archived` **Records:** `active`, `inactive`, `expired`, `cancelled` --- ## Example: Complete Support System Here's a full workflow combining all three object types: ```typescript import { app } from '@proveanything/smartlinks'; // 1. Customer submits a warranty claim (case) const claim = await app.cases.create(collectionId, appId, { visibility: 'owner', category: 'warranty', status: 'open', priority: 2, productId, proofId, contactId, data: { issue: 'Defective battery' }, owner: { serialNumber: 'SN-123' } }); // 2. Customer starts a discussion about the claim (thread) const discussion = await app.threads.create(collectionId, appId, { visibility: 'owner', parentType: 'case', parentId: claim.id, title: 'Questions about my warranty claim', body: { text: 'How long will the replacement take?' } }); // 3. Admin replies to the discussion await app.threads.reply(collectionId, appId, discussion.id, { authorId: 'admin_sarah', authorType: 'support_agent', text: 'We'll ship a replacement within 2 business days' }, true); // 4. Admin approves and creates a shipping record const shipment = await app.records.create(collectionId, appId, { recordType: 'shipment', parentType: 'case', parentId: claim.id, data: { carrier: 'UPS', tracking: 'UPS-123456789', estimatedDelivery: '2026-02-28' }, owner: { shippingAddress: '123 Main St' }, admin: { cost: 25.00, warehouse: 'CA-01' } }, true); // 5. Admin updates case with history await app.cases.appendHistory(collectionId, appId, claim.id, { entry: { action: 'replacement_shipped', tracking: 'UPS-123456789' }, historyTarget: 'owner', status: 'in_progress' }); // 6. Customer receives item, admin closes case await app.cases.update(collectionId, appId, claim.id, { status: 'resolved', admin: { resolvedBy: 'admin_sarah', satisfactionScore: 5 } }, true); // 7. Generate analytics const monthlyReport = await app.cases.summary(collectionId, appId, { period: { from: '2026-02-01', to: '2026-02-28' } }); ``` --- ## TypeScript Usage Import types and functions: ```typescript import { app, AppCase, AppThread, AppRecord, CreateCaseInput, CreateThreadInput, CreateRecordInput, PaginatedResponse, AggregateResponse } from '@proveanything/smartlinks'; // Fully typed const newCase: AppCase = await app.cases.create(collectionId, appId, { category: 'support', data: { issue: 'Login problem' } }); const threadList: PaginatedResponse = await app.threads.list( collectionId, appId, { limit: 50, sort: 'createdAt:desc' } ); ``` --- ## API Reference For complete endpoint documentation, query parameters, and response schemas, see: - [API_SUMMARY.md](./API_SUMMARY.md) — Full REST API reference - [TypeScript source](../src/types/appObjects.ts) — Type definitions - [API wrappers](../src/api/appObjects.ts) — Implementation --- ## Questions? These three object types are incredibly flexible building blocks. If you're unsure which to use for your use case, ask yourself: - Does it need tracking to closure? → **Case** - Is it a conversation or discussion? → **Thread** - Is it data with a lifecycle or hierarchy? → **Record** When in doubt, start with **Records** — they're the most generic and can be shaped to fit almost anything. --- # SDK guide: Proof Claiming Methods Source: https://docs.smartlinks.app/docs/sdk/guides/proof-claiming-methods NFC tags, serial numbers, virtual proofs, and auto-generated claims for product registration. # Proof Claiming Methods SmartLinks supports multiple methods for claiming/registering product ownership. Each method serves different use cases and product types. --- ## Overview of Claiming Methods | Method | Use Case | Requires Physical ID? | SDK Function | |--------|----------|---------------------|--------------| | **Tag-Based (NFC/QR)** | Physical products with tags | ✅ Yes | `proof.claim(collectionId, productId, proofId, data)` | | **Serial Number** | Products with printed codes | ✅ Yes | `proof.claim(collectionId, productId, proofId, data)` | | **Claimable/Virtual Proof** | Pre-allocated proofs (tickets, licenses) | ✅ Yes* | `proof.claim(collectionId, productId, proofId, data)` | | **Auto-Generated** | Generic product registration | ❌ No | `proof.claimProduct(collectionId, productId, data)` | *Proof ID provided via email/link rather than physical product --- ## Method 1-3: Claim with Proof ID **Use when:** You have a proof ID from an NFC tag, QR code, serial number, or pre-allocated proof. ### API ```typescript import { proof } from '@proveanything/smartlinks'; const claimed = await proof.claim( collectionId, // e.g., 'wine-collection' productId, // e.g., 'bordeaux-2020' proofId, // e.g., 'abc123-XY7Z' or 'a7Bx3' { // Optional: additional data purchaseDate: '2026-02-17', notes: 'First bottle from this vintage' } ); console.log('Claimed proof:', claimed.id); console.log('Owner:', claimed.userId); ``` ### Examples by ID Type **NFC Tag ID:** ```typescript // User scans NFC tag, app reads tag ID const tagId = "abc123-XY7Z"; const claimed = await proof.claim( 'wine-collection', 'bordeaux-2020', tagId ); ``` **Serial Number:** ```typescript // User types in serial from product packaging const serial = "a7Bx3"; const claimed = await proof.claim( 'electronics', 'headphones-pro', serial, { warranty: true } ); ``` **Pre-allocated Proof (Ticket/License):** ```typescript // User received proof ID via email const ticketId = "TICKET-VIP-001"; const claimed = await proof.claim( 'event-2026', 'vip-ticket', ticketId ); ``` --- ## Method 4: Auto-Generated Claims **Use when:** Product doesn't have unique identifiers, or you want simple post-purchase registration. ### Requirements The collection or product must have `allowAutoGenerateClaims` enabled. Apps will know if this is available based on product configuration. ### API ```typescript import { proof } from '@proveanything/smartlinks'; // No proof ID needed - system generates one automatically const claimed = await proof.claimProduct( collectionId, // e.g., 'beauty-brand' productId, // e.g., 'moisturizer-pro' { // Optional: user data purchaseDate: '2026-02-17', store: 'Target', notes: 'Love this product!' } ); console.log('Auto-generated ID:', claimed.id); // e.g., "a7Bx3" console.log('You now own this product'); ``` ### When to Use ✅ **Good for:** - Consumer products (cosmetics, household goods) - Post-purchase registration - Products without unique identifiers - Building product collections/wishlists ❌ **Avoid for:** - High-value items - Limited editions - Products requiring proof of purchase - Authenticity verification --- ## Choosing the Right Method Apps typically know which method to use based on product configuration: ```typescript const product = await product.get(collectionId, productId); if (product.admin?.allowAutoGenerateClaims) { // Show simple registration button return proof.claimProduct(collectionId, productId, userData) } />; } else { // Show input field for proof ID return proof.claim(collectionId, productId, proofId, userData) } />; } ``` ### Decision Tree ``` Do you have a proof ID? ├─ YES → Use proof.claim(collectionId, productId, proofId, data) └─ NO → Use proof.claimProduct(collectionId, productId, data) ``` --- ## Complete Examples ### Beauty Product Registration (Auto-Claim) ```typescript import { proof } from '@proveanything/smartlinks'; async function registerProduct() { try { const claimed = await proof.claimProduct( 'beauty-brand', 'moisturizer-pro', { purchaseDate: '2026-02-17', store: 'Sephora', skinType: 'combination' } ); alert(`Product registered! Your ID is: ${claimed.id}`); // Navigate to product dashboard } catch (error) { if (error.errorCode === 'FEATURE_NOT_ENABLED') { alert('This product requires a code to register'); } else { alert('Registration failed. Please try again.'); } } } ``` ### Wine Bottle Authentication (NFC Tag) ```typescript import { proof } from '@proveanything/smartlinks'; async function claimWineBottle(tagId: string) { try { const claimed = await proof.claim( 'wine-collection', 'bordeaux-2020', tagId, { purchaseDate: '2026-02-17', vendor: 'Fine Wine Shop', cellarLocation: 'Rack 3, Position 12' } ); console.log('Bottle authenticated and claimed!'); console.log('Proof ID:', claimed.id); } catch (error) { if (error.status === 404) { alert('Invalid tag ID'); } else if (error.status === 409) { alert('This bottle is already claimed'); } } } // User scans NFC tag nfcReader.addEventListener('reading', ({ serialNumber }) => { claimWineBottle(serialNumber); }); ``` ### Event Ticket Claiming ```typescript import { proof } from '@proveanything/smartlinks'; async function claimTicket(ticketId: string) { const claimed = await proof.claim( 'music-festival-2026', 'vip-pass', ticketId ); console.log('Ticket claimed!'); console.log('Seat:', claimed.data.seatNumber); console.log('Access level:', claimed.data.tier); } // User clicks email link with ticket ID const urlParams = new URLSearchParams(window.location.search); const ticketId = urlParams.get('ticket'); if (ticketId) { claimTicket(ticketId); } ``` --- ## Error Handling ### Common Errors ```typescript try { const claimed = await proof.claimProduct(collectionId, productId, data); } catch (error) { switch (error.errorCode) { case 'FEATURE_NOT_ENABLED': // Auto-claim not enabled for this product // Fall back to proof ID input break; case 'NOT_AUTHORIZED': // User not logged in // Redirect to login break; case 'RATE_LIMIT_EXCEEDED': // Too many claims // Show retry message break; case 'ALREADY_CLAIMED': // Proof ID already claimed by another user break; default: // Generic error handling break; } } ``` --- ## API Reference ### `proof.claim(collectionId, productId, proofId, data?)` Claim a proof using an existing proof ID (NFC tag, serial number, pre-allocated proof). **Parameters:** - `collectionId` (string) - Collection ID - `productId` (string) - Product ID - `proofId` (string) - The proof ID to claim - `data` (object, optional) - Additional data to attach to the proof **Returns:** `Promise` **Endpoint:** `PUT /public/collection/:collectionId/product/:productId/proof/:proofId/claim` --- ### `proof.claimProduct(collectionId, productId, data?)` Claim a product without a proof ID. System auto-generates a unique serial number. **Parameters:** - `collectionId` (string) - Collection ID - `productId` (string) - Product ID - `data` (object, optional) - User data to attach to the proof **Returns:** `Promise` with auto-generated `id` field **Endpoint:** `PUT /public/collection/:collectionId/product/:productId/proof/claim` **Requirements:** Collection or product must have `allowAutoGenerateClaims: true` --- ## TypeScript Definitions ### Collection ```typescript interface Collection { // ... other fields allowAutoGenerateClaims?: boolean // Enable auto-claim for all products } ``` ### Product ```typescript interface Product { // ... other fields admin?: { allowAutoGenerateClaims?: boolean // Override collection setting lastSerialId?: number // Last generated serial (auto-incremented) } } ``` --- **Last Updated:** February 17, 2026 --- ## Overview of Claiming Methods | Method | Use Case | Requires Physical ID? | Pre-Generation? | SDK Function | |--------|----------|---------------------|----------------|--------------| | **1. Tag-Based (NFC/QR)** | Physical products with tags | ✅ Yes | ✅ Yes | `proof.claim(proofId)` | | **2. Serial Number** | Products with printed codes | ✅ Yes | ✅ Yes | `proof.claim(proofId)` | | **3. Claimable Proof** | Pre-allocated virtual proofs | ❌ No* | ✅ Yes | `proof.claim(proofId)` | | **4. Virtual Proof** | Digital-only products | ❌ No | ✅ Yes | `proof.claim(proofId)` | | **5. Auto-Generated (NEW)** | Generic product registration | ❌ No | ❌ No | `proof.claimProduct()` | *Claimable proofs have a proof ID but user may receive it via email/link rather than physical product --- ## Method 1: Tag-Based Claims (NFC/QR) **Best for:** Physical products with embedded NFC chips or printed QR codes ### How It Works 1. Admin pre-generates claim sets with unique tag IDs 2. Physical tags are attached to products 3. User scans tag with phone → reads tag ID 4. User claims proof using tag ID ### Proof ID Format ``` {claimSetId}-{code} Example: "abc123-XY7Z" ``` ### Implementation **Admin: Generate claim set** ```typescript import { claimSet } from '@proveanything/smartlinks'; const result = await claimSet.create({ collectionId: 'wine-collection', productId: 'bordeaux-2020', count: 1000, // Generate 1000 unique tags type: 'nfc' // or 'qr' }); // Returns: { ids: ["abc123-0001", "abc123-0002", ...] } ``` **User: Claim via tag** ```typescript import { proof } from '@proveanything/smartlinks'; // User scans NFC tag, app reads tag ID const tagId = "abc123-XY7Z"; const claimed = await proof.claim(tagId, { collectionId: 'wine-collection', productId: 'bordeaux-2020' }); console.log('Claimed:', claimed.id); ``` ### Advantages - ✅ Highly secure (physical possession required) - ✅ Simple user experience (tap phone) - ✅ Works offline (scan → claim later) ### Disadvantages - ❌ Requires physical tags (cost) - ❌ Tags can be lost/damaged - ❌ Pre-generation needed --- ## Method 2: Serial Number Claims **Best for:** Products with printed serial numbers ### How It Works 1. Admin generates batch of serial numbers 2. Serial numbers printed on product packaging 3. User manually enters serial number 4. System validates and claims proof ### Proof ID Format ``` Base62-encoded with HMAC validation Example: "a7Bx3", "K9mP2" ``` ### Implementation **Admin: Generate serial numbers** ```typescript import { serialNumber } from '@proveanything/smartlinks'; const serials = await serialNumber.generate({ collectionId: 'electronics', productId: 'headphones-pro', count: 5000, startIndex: 1000 // Optional: continue from previous batch }); // Returns: ["a7Bx3", "a8Cy4", ...] // Print these on product packaging ``` **User: Claim via serial** ```typescript import { proof } from '@proveanything/smartlinks'; // User types in serial from product const serial = "a7Bx3"; const claimed = await proof.claim(serial, { collectionId: 'electronics', productId: 'headphones-pro' }); console.log('Product registered:', claimed.id); ``` ### Advantages - ✅ Lower cost (just printing) - ✅ Works on any product - ✅ Cryptographically secure ### Disadvantages - ❌ User must manually type code (typos) - ❌ Codes can be shared/leaked - ❌ Pre-generation needed --- ## Method 3: Claimable Proof Claims **Best for:** Event tickets, vouchers, promotional items ### How It Works 1. Admin creates claimable proofs (virtual state) 2. Proof IDs distributed via email/link 3. User clicks claim link or enters proof ID 4. Proof transitions from virtual → claimed ### Proof ID Format ``` Custom proof ID set by admin Example: "TICKET-2026-001", "VOUCHER-ABC" ``` ### Implementation **Admin: Create claimable proofs** ```typescript import { proof } from '@proveanything/smartlinks'; // Create single claimable proof const claimableProof = await proof.create({ collectionId: 'event-2026', productId: 'vip-ticket', id: 'TICKET-VIP-001', admin: true, data: { claimable: true, // Marks as unclaimed virtual: true, // Not yet associated with user seatNumber: 'A-12', tier: 'VIP' } }); // Send claim link to user const claimUrl = `https://yourapp.com/claim/TICKET-VIP-001`; sendEmail(user.email, `Your ticket: ${claimUrl}`); ``` **User: Claim proof** ```typescript import { proof } from '@proveanything/smartlinks'; const claimed = await proof.claim('TICKET-VIP-001', { collectionId: 'event-2026', productId: 'vip-ticket' }); // Proof now associated with user console.log('Ticket claimed by:', claimed.userId); ``` ### Advantages - ✅ Flexible distribution (email, SMS, link) - ✅ Can include pre-configured data - ✅ Easy to track claim status - ✅ Can set claim windows (time-limited) ### Disadvantages - ❌ Requires pre-creation - ❌ Link/ID can be shared - ❌ More complex admin workflow --- ## Method 4: Virtual Proof Claims **Best for:** Digital products, licenses, access codes ### How It Works Similar to claimable proofs, but typically used for purely digital products without physical counterpart. ### Implementation **Admin: Create virtual proof** ```typescript import { proof } from '@proveanything/smartlinks'; const virtualProof = await proof.create({ collectionId: 'software-licenses', productId: 'photo-editor-pro', id: 'LICENSE-2026-XYZ', admin: true, data: { virtual: true, claimable: true, licenseKey: 'XXXX-YYYY-ZZZZ', expiresAt: '2027-12-31' } }); ``` **User: Claim virtual proof** ```typescript const claimed = await proof.claim('LICENSE-2026-XYZ', { collectionId: 'software-licenses', productId: 'photo-editor-pro' }); console.log('License activated:', claimed.data.licenseKey); ``` ### Advantages - ✅ Perfect for digital goods - ✅ No physical logistics - ✅ Instant delivery ### Disadvantages - ❌ Same as claimable proofs - ❌ Codes can be easily shared --- ## Method 5: Auto-Generated Claims (NEW) **Best for:** Generic products without unique identifiers, post-purchase registration ### How It Works 1. Admin enables auto-claim on collection/product 2. User initiates claim without proof ID 3. System generates unique serial on-the-fly 4. Proof created immediately and claimed ### Proof ID Format ``` Auto-generated Base62 serial Example: "a7Bx3", "K9mP2" ``` ### Implementation **Admin: Enable auto-claim** ```typescript import { collection } from '@proveanything/smartlinks'; // Enable at collection level await collection.update('my-collection', { allowAutoGenerateClaims: true }); // Or override at product level await product.update('my-collection', 'moisturizer-pro', { admin: { allowAutoGenerateClaims: true } }); ``` **User: Claim without proof ID** ```typescript import { proof } from '@proveanything/smartlinks'; // No proof ID needed! const claimed = await proof.claimProduct({ collectionId: 'beauty-brand', productId: 'moisturizer-pro', data: { purchaseDate: '2026-02-17', store: 'Target', notes: 'Love this product!' } }); console.log('Your product ID:', claimed.id); // Auto-generated: "a7Bx3" ``` ### API Endpoint ``` PUT /public/collection/:collectionId/product/:productId/proof/claim ``` ### Configuration Priority 1. Product `admin.allowAutoGenerateClaims: false` → ❌ Disabled 2. Product `admin.allowAutoGenerateClaims: true` → ✅ Enabled 3. Collection `allowAutoGenerateClaims: true` → ✅ Enabled 4. Default → ❌ Disabled ### Advantages - ✅ No pre-generation required - ✅ Zero cost (no tags/printing) - ✅ Simple user experience - ✅ Perfect for non-serialized products - ✅ Atomic counter ensures uniqueness ### Disadvantages - ❌ No proof of physical ownership - ❌ Potential for spam/abuse (needs rate limiting) - ❌ Users can claim products they don't own ### Security Considerations **Rate limiting required:** ```typescript // Limit to 10 auto-claims per hour per user if (userClaimCount > 10) { throw new Error('Too many claims. Try again later.'); } ``` **Use cases where this is safe:** - Low-value consumer products - Products where ownership doesn't matter much - Post-purchase registration for benefits - Products sold through verified channels **Avoid for:** - High-value items - Limited editions - Products requiring proof of purchase - Collectibles --- ## Choosing the Right Method ### Decision Tree ``` Does product have a unique physical identifier? ├─ YES → Do you need high security? │ ├─ YES → NFC Tag (Method 1) │ └─ NO → Serial Number (Method 2) │ └─ NO → Is it a digital product? ├─ YES → Virtual Proof (Method 4) └─ NO → Do users need proof IDs in advance? ├─ YES → Claimable Proof (Method 3) └─ NO → Auto-Generated (Method 5) ``` ### Use Case Examples | Product Type | Recommended Method | Reason | |--------------|-------------------|---------| | Wine bottles | NFC Tag | High value, authentication important | | Electronics | Serial Number | Already have serial numbers | | Event tickets | Claimable Proof | Need to distribute in advance | | Software licenses | Virtual Proof | Digital product | | Cosmetics | Auto-Generated | Low value, post-purchase registration | | Limited sneakers | NFC Tag | High value, prevent fraud | | Household goods | Auto-Generated | Simple registration, low fraud risk | --- ## Combining Methods You can use multiple methods for the same product: ```typescript // Product supports both serial numbers AND auto-claim const product = { id: 'premium-headphones', serialNumbersEnabled: true, // Traditional serial claiming admin: { allowAutoGenerateClaims: true // Also allow auto-claim } }; // User path 1: Has serial from box await proof.claim('a7Bx3', { collectionId, productId }); // User path 2: Lost box, just wants to register await proof.claimProduct({ collectionId, productId }); ``` --- ## API Summary ### All Claim Methods ```typescript import { proof } from '@proveanything/smartlinks'; // Methods 1-4: Claim with proof ID await proof.claim(proofId, { collectionId: string, productId: string, data?: any // Optional additional data }); // Method 5: Claim without proof ID await proof.claimProduct({ collectionId: string, productId: string, data?: any // Optional additional data }); ``` ### Check Claim Status ```typescript const proofData = await proof.get(proofId); if (proofData.claimable && proofData.virtual) { console.log('Proof is unclaimed'); } else if (proofData.userId) { console.log('Proof claimed by:', proofData.userId); } ``` ### Admin: Check Product Settings ```typescript const product = await product.get(collectionId, productId); // Check what claiming methods are available const methods = { hasSerialNumbers: product.admin?.lastSerialId > 0, allowsAutoClaim: product.admin?.allowAutoGenerateClaims === true, hasClaimSets: product.claimSets?.length > 0 }; console.log('Available claiming methods:', methods); ``` --- ## Migration Guide ### Adding Auto-Claim to Existing Products ```typescript // Enable for all cosmetics products const products = await product.list('beauty-collection'); for (const prod of products) { if (prod.category === 'cosmetics') { await product.update('beauty-collection', prod.id, { admin: { ...prod.admin, allowAutoGenerateClaims: true } }); } } ``` ### Analytics: Track Claim Method ```typescript // When creating proof, tag with claim method const proof = await proof.create({ // ... other fields metadata: { claimMethod: 'auto_generated' | 'serial' | 'nfc' | 'qr' | 'claimable' } }); // Query by method const autoClaimedProofs = await firestore .collection('ledger') .where('metadata.claimMethod', '==', 'auto_generated') .get(); ``` --- ## Related Documentation - [Claim Sets](./claim-sets.md) - Managing NFC/QR tag batches - [Serial Numbers](./serial-numbers.md) - Cryptographic serial generation - [Proof Ownership](./proof-ownership.md) - Managing claimed proofs - [User App Data](./app-data-storage.md) - Storing user preferences per proof --- **Last Updated:** February 17, 2026 --- # SDK guide: AI & Chat Completions Source: https://docs.smartlinks.app/docs/sdk/guides/ai Chat completions, RAG product assistants, voice integration, and podcast generation. # SmartLinks AI Build AI-powered SmartLinks experiences with a practical SDK guide for responses, chat, product assistants, streaming, voice, and real-world integration patterns. --- ## Table of Contents - [Overview](#overview) - [Quick Start](#quick-start) - [Authentication](#authentication) - [Responses API](#responses-api) - [Chat Completions](#chat-completions) - [RAG: Product Assistants](#rag-product-assistants) - [Voice Integration](#voice-integration) - [Podcast Generation](#podcast-generation) - [Type Definitions](#type-definitions) - [API Reference](#api-reference) - [Usage Examples](#usage-examples) - [Error Handling](#error-handling) - [Rate Limiting](#rate-limiting) - [Best Practices](#best-practices) - [Providing Content to the AI Assistant](#providing-content-to-the-ai-assistant) --- ## Overview This guide is written for SDK users building real products, not backend operators. It focuses on the public SDK surface, recommended starting points, and examples you can adapt directly. ### Start with the path that matches your job | If you want to... | Start here | |---|---| | Build a new AI workflow | Use [Responses API](#responses-api) | | Add compatibility with existing chat clients | Use [Chat Completions](#chat-completions) | | Build a product/manual assistant | Use [RAG: Product Assistants](#rag-product-assistants) | | Add spoken input/output | Use [Voice Integration](#voice-integration) | | Add progressive rendering | Use [Streaming Responses](#streaming-responses) or [Streaming Chat](#streaming-chat) | ### Recommended starting points - New AI features: start with `ai.chat.responses.create(...)` - Product/manual assistants: start with `ai.public.chat(...)` - Existing OpenAI-style clients: use `ai.chat.completions.create(...)` - Real-time voice: use `ai.public.getToken(...)` and your provider's live client SmartLinks AI provides five main capabilities: 1. **Responses API** - Preferred API for agentic workflows, multimodal inputs, and tool-driven responses 2. **Chat Completions** - OpenAI-compatible text generation with streaming and tool calling 3. **RAG (Retrieval-Augmented Generation)** - Document-grounded Q&A for product assistants 4. **Voice Integration** - Voice-to-text and text-to-voice for hands-free interaction 5. **Podcast Generation** - NotebookLM-style multi-voice conversational podcasts from documents ### Key Features - ✅ Full TypeScript support with type safety - ✅ Streaming responses with async iterators - ✅ Automatic rate limit handling - ✅ Session management for conversations - ✅ Voice input/output helpers - ✅ Tool/function calling support - ✅ OpenAI-style Responses API support - ✅ Document indexing and retrieval - ✅ Customizable assistant behavior --- ## Quick Start If you're only reading one section, start here. The three snippets below cover the most common public SDK use cases. ### 1. Generate a response ```typescript import { initializeApi, ai } from '@proveanything/smartlinks'; // Initialize the SDK initializeApi({ baseURL: 'https://smartlinks.app/api/v1', apiKey: process.env.SMARTLINKS_API_KEY // Required for admin endpoints }); // Preferred: create a response const response = await ai.chat.responses.create('my-collection', { model: 'google/gemini-2.5-flash', input: 'Summarize the key safety steps for descaling a coffee maker.' }); console.log(response.output_text); ``` ### 2. Build a product assistant ```typescript import { initializeApi, ai } from '@proveanything/smartlinks'; initializeApi({ baseURL: 'https://smartlinks.app/api/v1' }); const answer = await ai.public.chat('my-collection', { productId: 'coffee-maker-deluxe', userId: 'user-123', message: 'How do I descale this machine?' }); console.log(answer.message); ``` ### 3. Stream output into your UI ```typescript const stream = await ai.chat.responses.create('my-collection', { input: 'Write a launch checklist for a new product page.', stream: true }); for await (const event of stream) { if (event.type === 'response.output_text.delta') { updateUi(event.delta); } } ``` --- ## Authentication ### Admin Endpoints Admin endpoints require an API key passed during initialization: ```typescript initializeApi({ baseURL: 'https://smartlinks.app/api/v1', apiKey: process.env.SMARTLINKS_API_KEY }); ``` The SDK automatically includes the API key in the `Authorization: Bearer ` header. ### Public Endpoints Public endpoints don't require an API key but are rate-limited by `userId`: ```typescript // No API key needed const response = await ai.public.chat('my-collection', { productId: 'coffee-maker', userId: 'user-123', message: 'How do I clean this?' }); ``` --- ## Responses API The Responses API is the recommended starting point for new integrations. Use it when you want a single endpoint for structured input, tool use, and streaming output. ### Basic Response ```typescript const response = await ai.chat.responses.create('my-collection', { model: 'google/gemini-2.5-flash', input: 'Write a friendly two-sentence welcome for a product assistant.' }); console.log(response.output_text); ``` ### Multimessage Input ```typescript const response = await ai.chat.responses.create('my-collection', { model: 'google/gemini-2.5-flash', input: [ { role: 'system', content: [ { type: 'input_text', text: 'You are a concise support assistant.' } ] }, { role: 'user', content: [ { type: 'input_text', text: 'Give me three troubleshooting steps for a grinder that will not start.' } ] } ] }); console.log(response.output_text); ``` ### Streaming Responses When you pass `stream: true`, the SDK returns an `AsyncIterable` of SSE events instead of a final JSON object. You do not need to parse raw SSE frames yourself — just iterate with `for await...of`. ```typescript const result = await ai.chat.responses.create('my-collection', { input: 'Summarize the manual', stream: true }); for await (const event of result) { if (event.type === 'response.output_text.delta') { process.stdout.write(event.delta); } } ``` If you omit `stream: true`, the same method returns the final `ResponsesResult` object instead. ```typescript const stream = await ai.chat.responses.create('my-collection', { model: 'google/gemini-2.5-flash', input: 'Explain how to descale an espresso machine step by step.', stream: true }); for await (const event of stream) { if (event.type === 'response.output_text.delta') { process.stdout.write(event.delta); } } ``` ### Tool Calling ```typescript const response = await ai.chat.responses.create('my-collection', { model: 'google/gemini-2.5-flash', input: 'What is the weather in Paris?', tools: [ { type: 'function', name: 'get_weather', description: 'Get the current weather for a city', parameters: { type: 'object', properties: { location: { type: 'string' } }, required: ['location'] } } ] }); console.log(response.output); ``` --- ## Chat Completions OpenAI-compatible chat completions with streaming and tool calling support. Use this for compatibility with existing Chat Completions integrations; prefer the Responses API for new agentic features. ### Basic Chat ```typescript const response = await ai.chat.completions.create('my-collection', { model: 'google/gemini-2.5-flash', messages: [ { role: 'system', content: 'You are a helpful assistant.' }, { role: 'user', content: 'What is the capital of France?' } ] }); console.log(response.choices[0].message.content); // Output: "The capital of France is Paris." ``` ### Streaming Chat Stream responses in real-time for better UX: - Set `stream: true` - The SDK returns an `AsyncIterable` - Iterate over chunks with `for await...of` - Read incremental text from `chunk.choices[0]?.delta?.content` - If `stream` is omitted or `false`, the method returns the normal `ChatCompletionResponse` ```typescript const stream = await ai.chat.completions.create('my-collection', { model: 'google/gemini-2.5-flash', messages: [ { role: 'user', content: 'Write a short poem about coding' } ], stream: true }); for await (const chunk of stream) { const content = chunk.choices[0]?.delta?.content || ''; process.stdout.write(content); } ``` ### Tool/Function Calling Define tools (functions) that the AI can call: ```typescript const tools = [ { type: 'function', function: { name: 'get_weather', description: 'Get the current weather for a location', parameters: { type: 'object', properties: { location: { type: 'string', description: 'City name' }, unit: { type: 'string', enum: ['celsius', 'fahrenheit'] } }, required: ['location'] } } } ]; const response = await ai.chat.completions.create('my-collection', { model: 'google/gemini-2.5-flash', messages: [ { role: 'user', content: 'What\'s the weather in Paris?' } ], tools }); const toolCall = response.choices[0].message.tool_calls?.[0]; if (toolCall) { console.log('Function:', toolCall.function.name); console.log('Arguments:', JSON.parse(toolCall.function.arguments)); // { location: "Paris", unit: "celsius" } } ``` ### Available Models ```typescript // List all available models const models = await ai.models.list('my-collection'); // Or filter by provider / capability const openAiModels = await ai.models.list('my-collection', { provider: 'openai' }); const visionModels = await ai.models.list('my-collection', { capability: 'vision' }); models.data.forEach(model => { console.log(`${model.name}`); console.log(` Provider: ${model.provider}`); console.log(` Context: ${model.contextWindow} tokens`); console.log(` Pricing: $${model.pricing.input}/1M input tokens`); }); // Get specific model info const model = await ai.models.get('my-collection', 'google/gemini-2.5-flash'); console.log(model.capabilities); // ['text', 'vision', 'audio', 'code'] ``` Use `ai.models.list(collectionId)` as the source of truth for what your collection can use at runtime. The public docs provide recommendations, but actual availability depends on the SmartLinks model catalog exposed to that collection. **Recommended Models:** | Model | Use Case | Speed | Cost | |-------|----------|-------|------| | `openai/gpt-5.4` | Default for new agentic and structured-output workflows | Balanced | Medium | | `openai/gpt-5-mini` | Lower-cost general purpose and JSON tasks | Fast | Low | | `google/gemini-2.5-flash` | Fast multimodal and cost-sensitive general use | Fast | Low | | `google/gemini-2.5-pro` | Complex reasoning and heavier multimodal tasks | Slower | Higher | If you want a safe default for most new work, start with `openai/gpt-5.4`. If you want a lower-cost fallback, use `openai/gpt-5-mini` or `google/gemini-2.5-flash` depending on your latency and pricing goals. --- ## RAG: Product Assistants Create intelligent product assistants that answer questions based on product documentation. ### Setup: Index Documents First, index your product documentation: ```typescript // Index a product manual from URL const result = await ai.rag.indexDocument('my-collection', { productId: 'coffee-maker-deluxe', documentUrl: 'https://example.com/manuals/coffee-maker.pdf', chunkSize: 500, // Tokens per chunk overlap: 50, // Token overlap between chunks provider: 'openai' // Embedding provider }); console.log(`Indexed ${result.chunks} chunks`); console.log(`Dimensions: ${result.metadata.embeddingDimensions}`); // Or index from text directly await ai.rag.indexDocument('my-collection', { productId: 'coffee-maker-deluxe', text: 'Your product manual content here...', metadata: { source: 'manual', version: '2.0' } }); ``` ### Configure Assistant Customize the assistant's behavior: ```typescript await ai.rag.configureAssistant('my-collection', { productId: 'coffee-maker-deluxe', systemPrompt: 'You are a helpful coffee maker assistant. Be concise and friendly.', model: 'google/gemini-2.5-flash', temperature: 0.7, maxTokensPerResponse: 500, rateLimitPerUser: 20, allowedTopics: ['usage', 'cleaning', 'troubleshooting'], customInstructions: { tone: 'friendly', additionalRules: 'Always include safety warnings when relevant.' } }); ``` ### Public Chat Users can chat with the product assistant without authentication: ```typescript // First question const response = await ai.public.chat('my-collection', { productId: 'coffee-maker-deluxe', userId: 'user-123', message: 'How do I descale my coffee maker?' }); console.log('Answer:', response.message); console.log('Used', response.context.chunksUsed, 'document sections'); console.log('Top similarity:', response.context.topSimilarity); ``` ### Conversation History Maintain conversation context with sessions: ```typescript const sessionId = `session-${Date.now()}`; // First question const q1 = await ai.public.chat('my-collection', { productId: 'coffee-maker-deluxe', userId: 'user-123', message: 'How do I clean it?', sessionId }); // Follow-up question (uses history) const q2 = await ai.public.chat('my-collection', { productId: 'coffee-maker-deluxe', userId: 'user-123', message: 'How often should I do that?', sessionId }); // Get full conversation history const session = await ai.public.getSession('my-collection', sessionId); console.log('Messages:', session.messages); console.log('Total messages:', session.messageCount); // Clear session when done await ai.public.clearSession('my-collection', sessionId); ``` ### Session Management ```typescript // Get session statistics (admin) const stats = await ai.sessions.stats('my-collection'); console.log('Total sessions:', stats.totalSessions); console.log('Active sessions:', stats.activeSessions); console.log('Total messages:', stats.totalMessages); console.log('Rate-limited users:', stats.rateLimitedUsers); ``` --- ## Voice Integration Enable voice input and output for hands-free interaction. ### Voice Patterns The SDK supports three practical voice patterns: | Pattern | Best For | SDK Building Blocks | |---------|----------|---------------------| | Voice → Text → AI → Text | Manual helper Q&A, troubleshooting steps | `ai.voice.listen()` + `ai.public.chat()` | | Voice → Text → AI → Voice | Hands-free assistants, accessibility | `ai.voice.listen()` + `ai.public.chat()` + `ai.voice.speak()` or `ai.tts.generate()` | | Real-time Voice | Low-latency spoken conversation | `ai.public.getToken()` + Gemini Live client | ### Current SDK Support - `ai.voice.listen()` and `ai.voice.speak()` are browser helpers built on the Web Speech APIs. - `ai.public.getToken()` generates ephemeral tokens for Gemini Live sessions. - `ai.tts.generate()` supports server-side text-to-speech generation. - The SDK does not currently expose a first-class transcription endpoint like Whisper; if you need that flow, implement it as your own backend endpoint and feed the transcribed text into `ai.public.chat()` or `ai.chat.responses.create()`. ### Recommended Approach For product assistants and RAG-backed support, start with Voice → Text → AI → Text/Voice. It gives you the best control over retrieval, session history, and cost. Use Gemini Live when low-latency spoken conversation matters more than deep document grounding. ### Browser Voice Helpers These helpers are browser-only and rely on native speech recognition / speech synthesis support. ```typescript // Check if voice is supported if (ai.voice.isSupported()) { // Listen for voice input const question = await ai.voice.listen('en-US'); console.log('User said:', question); // Get answer from AI const response = await ai.public.chat('my-collection', { productId: 'coffee-maker-deluxe', userId: 'user-123', message: question }); // Speak the answer await ai.voice.speak(response.message, { voice: 'alloy', rate: 1.0 }); } ``` ### Voice Assistant Class Create a complete voice assistant: ```typescript class ProductVoiceAssistant { private collectionId: string; private productId: string; private userId: string; private sessionId: string; constructor(config: { collectionId: string; productId: string; userId: string; }) { this.collectionId = config.collectionId; this.productId = config.productId; this.userId = config.userId; this.sessionId = `voice-${Date.now()}`; } async ask(): Promise { // Listen for question console.log('Listening...'); const question = await ai.voice.listen(); // Get answer console.log('Processing...'); const response = await ai.public.chat(this.collectionId, { productId: this.productId, userId: this.userId, message: question, sessionId: this.sessionId }); // Speak answer console.log('Speaking...'); await ai.voice.speak(response.message); return response.message; } async getRemainingQuestions(): Promise { const status = await ai.public.getRateLimit(this.collectionId, this.userId); return status.remaining; } } // Usage const assistant = new ProductVoiceAssistant({ collectionId: 'my-collection', productId: 'coffee-maker-deluxe', userId: 'user-123' }); await assistant.ask(); // Voice question → Voice answer const remaining = await assistant.getRemainingQuestions(); console.log(`${remaining} questions remaining`); ``` ### Gemini Live Integration Generate ephemeral tokens for Gemini Live (multimodal voice): Use this path for real-time voice sessions. The SDK only issues the short-lived token; the actual live connection is made with the provider client. ```typescript // Generate token for voice session const token = await ai.public.getToken('my-collection', { settings: { ttl: 3600, // 1 hour voice: 'alloy', language: 'en-US' } }); console.log('Token:', token.token); console.log('Expires at:', new Date(token.expiresAt)); // Use token with Gemini Live API // (See Google's Gemini documentation) ``` ### Voice + RAG Guidance For document-grounded assistants, prefer this pattern: 1. Capture voice with `ai.voice.listen()` or your own transcription flow. 2. Send the transcribed text to `ai.public.chat()`. 3. Render the text response for readability. 4. Optionally speak the answer with `ai.voice.speak()` or `ai.tts.generate()`. This is usually a better fit for manuals and procedural guidance than trying to use a live voice session as the primary retrieval layer. --- ## Podcast Generation Generate NotebookLM-style multi-voice conversational podcasts from product documentation. ### Generate a Podcast ```typescript const podcast = await ai.podcast.generate('my-collection', { productId: 'coffee-maker-deluxe', duration: 5, // Target 5 minutes style: 'casual', // 'casual' | 'professional' | 'educational' | 'entertaining' voices: { host1: 'nova', // Female voice host2: 'onyx' // Male voice }, includeAudio: true // Generate audio files }); console.log('Podcast Title:', podcast.script.title); console.log('Duration:', podcast.metadata.duration, 'seconds'); console.log('Download:', podcast.audio?.mixedUrl); ``` ### Available Voices | Voice | Gender | Personality | Best For | |-------|--------|-------------|----------| | `alloy` | Neutral | Balanced, neutral | Professional podcasts | | `echo` | Male | Clear, authoritative | Expert/teacher role | | `fable` | Neutral | Warm, storytelling | Narrative content | | `onyx` | Male | Deep, engaging | Main host, discussions | | `nova` | Female | Friendly, enthusiastic | Co-host, questions | | `shimmer` | Female | Bright, energetic | Entertaining content | **Recommended Combinations:** - **Casual**: Nova + Onyx - Friendly and engaging - **Professional**: Alloy + Echo - Authoritative and clear - **Educational**: Fable + Echo - Teaching style - **Entertaining**: Shimmer + Onyx - High energy ### Access the Script ```typescript // View the generated script podcast.script.segments.forEach((segment, i) => { const speaker = segment.speaker === 'host1' ? 'Host 1' : 'Host 2'; console.log(`${speaker}: ${segment.text}`); }); ``` ### Check Generation Status For long-running podcast generation, poll for status: ```typescript // Start generation const podcast = await ai.podcast.generate('my-collection', { productId: 'coffee-maker-deluxe', duration: 10, includeAudio: true }); // Poll for status const checkStatus = async () => { const status = await ai.podcast.getStatus('my-collection', podcast.podcastId); console.log(`Status: ${status.status} (${status.progress}%)`); if (status.status === 'completed' && status.result) { console.log('Podcast ready!'); console.log('Listen:', status.result.audio?.mixedUrl); return true; } else if (status.status === 'failed') { console.error('Generation failed:', status.error); return true; } return false; }; // Check every 5 seconds const interval = setInterval(async () => { const done = await checkStatus(); if (done) clearInterval(interval); }, 5000); ``` ### Text-to-Speech (TTS) Generate custom audio from text: ```typescript const audioBlob = await ai.tts.generate('my-collection', { text: 'Welcome to our podcast about coffee makers!', voice: 'nova', speed: 1.0, format: 'mp3' }); // Create audio URL for playback const audioUrl = URL.createObjectURL(audioBlob); ``` --- ## Type Definitions ### Core Types ```typescript /** * Chat message with role and content */ interface ChatMessage { role: 'system' | 'user' | 'assistant' | 'function' | 'tool'; content: string | ContentPart[]; name?: string; function_call?: FunctionCall; tool_calls?: ToolCall[]; tool_call_id?: string; } /** * Chat completion request */ interface ChatCompletionRequest { messages: ChatMessage[]; model?: string; stream?: boolean; tools?: ToolDefinition[]; tool_choice?: 'none' | 'auto' | 'required' | { type: 'function'; function: { name: string } }; temperature?: number; // 0-2, default: 0.7 max_tokens?: number; top_p?: number; frequency_penalty?: number; presence_penalty?: number; response_format?: { type: 'text' | 'json_object' }; user?: string; } /** * Chat completion response */ interface ChatCompletionResponse { id: string; object: 'chat.completion'; created: number; model: string; choices: ChatCompletionChoice[]; usage: { prompt_tokens: number; completion_tokens: number; total_tokens: number; }; } /** * Streaming chunk */ interface ChatCompletionChunk { id: string; object: 'chat.completion.chunk'; created: number; model: string; choices: Array<{ index: number; delta: Partial; finish_reason: string | null; }>; } ``` ### RAG Types ```typescript /** * Index document request */ interface IndexDocumentRequest { productId: string; text?: string; // Either text or documentUrl required documentUrl?: string; metadata?: Record; chunkSize?: number; // Default: 500 overlap?: number; // Default: 50 provider?: 'openai' | 'gemini'; } /** * Configure assistant request */ interface ConfigureAssistantRequest { productId: string; systemPrompt?: string; model?: string; maxTokensPerResponse?: number; temperature?: number; rateLimitPerUser?: number; allowedTopics?: string[]; customInstructions?: Record; } /** * Public chat request */ interface PublicChatRequest { productId: string; userId: string; message: string; sessionId?: string; stream?: boolean; } /** * Public chat response */ interface PublicChatResponse { message: string; sessionId: string; usage: { prompt_tokens: number; completion_tokens: number; total_tokens: number; }; context?: { chunksUsed: number; topSimilarity: number; }; } ``` ### Podcast Types ```typescript /** * Podcast generation request */ interface GeneratePodcastRequest { productId: string; documentText?: string; // Optional if document already indexed duration?: number; // Target duration in minutes (default: 10) style?: 'casual' | 'professional' | 'educational' | 'entertaining'; voices?: { host1?: string; // Voice name for first host host2?: string; // Voice name for second host }; includeAudio?: boolean; // Generate audio files (default: false) language?: string; // Default: 'en-US' customInstructions?: string; } /** * Podcast script segment */ interface PodcastSegment { speaker: 'host1' | 'host2'; text: string; timestamp?: number; // Start time in seconds duration?: number; // Segment duration } /** * Podcast script */ interface PodcastScript { title: string; description: string; segments: PodcastSegment[]; } /** * Podcast generation response */ interface GeneratePodcastResponse { success: boolean; podcastId: string; script: PodcastScript; audio?: { host1Url?: string; // URL to download host 1 audio host2Url?: string; // URL to download host 2 audio mixedUrl?: string; // URL to download mixed podcast }; metadata: { duration: number; // Actual duration in seconds wordCount: number; generatedAt: string; }; } /** * Podcast status */ interface PodcastStatus { podcastId: string; status: 'generating_script' | 'generating_audio' | 'mixing' | 'completed' | 'failed'; progress: number; // 0-100 estimatedTimeRemaining?: number; // Seconds error?: string; result?: GeneratePodcastResponse; // Available when completed } /** * TTS request */ interface TTSRequest { text: string; voice?: 'alloy' | 'echo' | 'fable' | 'onyx' | 'nova' | 'shimmer'; speed?: number; // 0.25 - 4.0, default: 1.0 format?: 'mp3' | 'opus' | 'aac' | 'flac'; // Default: mp3 } ``` ### Error Types ```typescript /** * API Error response */ interface AIError { error: { message: string; type: string; code: string; param?: string; resetAt?: string; // ISO 8601 timestamp (for rate limits) }; } /** * Custom error class */ class SmartLinksAIError extends Error { type: string; code: string; statusCode: number; resetAt?: string; isRateLimitError(): boolean; isAuthError(): boolean; isNotFoundError(): boolean; } ``` --- ## API Reference ### Admin Endpoints #### `ai.chat.completions.create(collectionId, request)` Create a chat completion (OpenAI-compatible). **Parameters:** - `collectionId` (string) - Collection ID - `request` (ChatCompletionRequest) - Request parameters **Returns:** `Promise>` **Example:** ```typescript const response = await ai.chat.completions.create('my-collection', { model: 'google/gemini-2.5-flash', messages: [{ role: 'user', content: 'Hello!' }] }); ``` --- #### `ai.models.list(collectionId)` List available AI models. **Returns:** `Promise` --- #### `ai.models.get(collectionId, modelId)` Get specific model information. **Parameters:** - `modelId` (string) - Model identifier (e.g., 'google/gemini-2.5-flash') **Returns:** `Promise` --- #### `ai.rag.indexDocument(collectionId, request)` Index a document for RAG. **Parameters:** - `request` (IndexDocumentRequest) - Document and indexing parameters **Returns:** `Promise` --- #### `ai.rag.configureAssistant(collectionId, request)` Configure AI assistant behavior. **Parameters:** - `request` (ConfigureAssistantRequest) - Assistant configuration **Returns:** `Promise` --- #### `ai.sessions.stats(collectionId)` Get session statistics. **Returns:** `Promise` --- #### `ai.rateLimit.reset(collectionId, userId)` Reset rate limit for a user. **Returns:** `Promise<{ success: boolean; userId: string }>` --- #### `ai.podcast.generate(collectionId, request)` Generate a NotebookLM-style conversational podcast. **Parameters:** - `request` (GeneratePodcastRequest) - Podcast generation parameters **Returns:** `Promise` **Example:** ```typescript const podcast = await ai.podcast.generate('my-collection', { productId: 'coffee-maker-deluxe', duration: 5, style: 'casual', voices: { host1: 'nova', host2: 'onyx' }, includeAudio: true }); ``` --- #### `ai.podcast.getStatus(collectionId, podcastId)` Get podcast generation status. **Parameters:** - `podcastId` (string) - Podcast identifier **Returns:** `Promise` --- #### `ai.tts.generate(collectionId, request)` Generate text-to-speech audio. **Parameters:** - `request` (TTSRequest) - TTS parameters **Returns:** `Promise` **Example:** ```typescript const audioBlob = await ai.tts.generate('my-collection', { text: 'Welcome to our podcast!', voice: 'nova', speed: 1.0 }); ``` --- ### Public Endpoints #### `ai.public.chat(collectionId, request)` Chat with product assistant (no auth required). **Parameters:** - `request` (PublicChatRequest) - Chat parameters **Returns:** `Promise` **Rate Limited:** Yes (20 requests/hour per userId by default) --- #### `ai.public.getSession(collectionId, sessionId)` Get conversation history. **Returns:** `Promise` --- #### `ai.public.clearSession(collectionId, sessionId)` Clear conversation history. **Returns:** `Promise<{ success: boolean }>` --- #### `ai.public.getRateLimit(collectionId, userId)` Check rate limit status. **Returns:** `Promise` --- #### `ai.public.getToken(collectionId, request)` Generate ephemeral token for Gemini Live. **Returns:** `Promise` --- ## Usage Examples ### Example 1: Product FAQ Bot ```typescript async function createProductFAQ() { const collectionId = 'my-collection'; const productId = 'coffee-maker-deluxe'; // 1. Index product documentation await ai.rag.indexDocument(collectionId, { productId, documentUrl: 'https://example.com/manual.pdf' }); // 2. Configure assistant await ai.rag.configureAssistant(collectionId, { productId, systemPrompt: 'You are a coffee maker expert. Provide clear, step-by-step instructions.', rateLimitPerUser: 30 }); // 3. Answer user questions const answer = await ai.public.chat(collectionId, { productId, userId: 'user-123', message: 'How do I make espresso?' }); console.log(answer.message); } ``` ### Example 2: Streaming Chatbot UI ```typescript async function streamingChatbot(userMessage: string) { const stream = await ai.chat.completions.create('my-collection', { model: 'google/gemini-2.5-flash', messages: [ { role: 'system', content: 'You are a helpful assistant.' }, { role: 'user', content: userMessage } ], stream: true }); let fullResponse = ''; for await (const chunk of stream) { const content = chunk.choices[0]?.delta?.content || ''; fullResponse += content; // Update UI in real-time updateChatUI(content); } return fullResponse; } ``` ### Example 3: Multi-Turn Conversation ```typescript async function chatConversation() { const collectionId = 'my-collection'; const sessionId = `chat-${Date.now()}`; const userId = 'user-123'; const productId = 'coffee-maker-deluxe'; // Question 1 const a1 = await ai.public.chat(collectionId, { productId, userId, message: 'How do I clean the machine?', sessionId }); console.log('A1:', a1.message); // Question 2 (references previous context) const a2 = await ai.public.chat(collectionId, { productId, userId, message: 'How often should I do that?', sessionId }); console.log('A2:', a2.message); // Get full history const session = await ai.public.getSession(collectionId, sessionId); console.log('Full conversation:', session.messages); } ``` ### Example 4: React Hook for Product Assistant ```typescript import { useState, useCallback } from 'react'; import { ai } from '@proveanything/smartlinks'; export function useProductAssistant( collectionId: string, productId: string, userId: string ) { const [loading, setLoading] = useState(false); const [error, setError] = useState(null); const [rateLimit, setRateLimit] = useState({ remaining: 20, limit: 20 }); const ask = useCallback(async (message: string) => { setLoading(true); setError(null); try { const response = await ai.public.chat(collectionId, { productId, userId, message }); setRateLimit(prev => ({ ...prev, remaining: prev.remaining - 1 })); return response.message; } catch (err: any) { setError(err.message); throw err; } finally { setLoading(false); } }, [collectionId, productId, userId]); return { ask, loading, error, rateLimit }; } // Usage in component function ProductHelp() { const { ask, loading, rateLimit } = useProductAssistant( 'my-collection', 'coffee-maker', 'user-123' ); const [answer, setAnswer] = useState(''); const handleAsk = async () => { const response = await ask('How do I clean this?'); setAnswer(response); }; return (
{answer &&

{answer}

}

{rateLimit.remaining} questions remaining

); } ``` ### Example 5: Voice Q&A ```typescript async function voiceQA() { if (!ai.voice.isSupported()) { console.error('Voice not supported in this browser'); return; } console.log('Speak your question...'); // Listen for voice input const question = await ai.voice.listen('en-US'); console.log('You asked:', question); // Get answer from AI const response = await ai.public.chat('my-collection', { productId: 'coffee-maker-deluxe', userId: 'user-123', message: question }); // Display answer console.log('Answer:', response.message); // Speak answer await ai.voice.speak(response.message); } ``` ### Example 6: Generate Product Podcast ```typescript async function generateProductPodcast() { // Generate a casual 5-minute podcast about the coffee maker const podcast = await ai.podcast.generate('my-collection', { productId: 'coffee-maker-deluxe', duration: 5, // minutes style: 'casual', // Conversational style voices: { host1: 'nova', // Female voice host2: 'onyx' // Male voice }, includeAudio: true }); console.log('Podcast Title:', podcast.script.title); console.log('Duration:', podcast.metadata.duration, 'seconds'); // Display script console.log('\nScript:'); podcast.script.segments.forEach((segment, i) => { const speaker = segment.speaker === 'host1' ? 'Host 1' : 'Host 2'; console.log(`\n${speaker}: ${segment.text}`); }); // Download audio if (podcast.audio?.mixedUrl) { console.log('\nDownload podcast:', podcast.audio.mixedUrl); } } ``` ### Example 7: Podcast with Progress Tracking ```typescript async function generatePodcastWithProgress() { // Start podcast generation const podcast = await ai.podcast.generate('my-collection', { productId: 'coffee-maker-deluxe', duration: 10, style: 'professional', includeAudio: true }); const podcastId = podcast.podcastId; // Poll for status const checkStatus = async () => { const status = await ai.podcast.getStatus('my-collection', podcastId); console.log(`Status: ${status.status} (${status.progress}%)`); if (status.status === 'completed' && status.result) { console.log('Podcast ready!'); console.log('Listen:', status.result.audio?.mixedUrl); return true; } else if (status.status === 'failed') { console.error('Generation failed:', status.error); return true; } return false; }; // Check every 5 seconds const interval = setInterval(async () => { const done = await checkStatus(); if (done) clearInterval(interval); }, 5000); } ``` --- ## Error Handling ### Error Codes | Code | Type | HTTP Status | Description | |------|------|-------------|-------------| | `rate_limit_exceeded` | `rate_limit_error` | 429 | User exceeded rate limit | | `invalid_request` | `invalid_request_error` | 400 | Invalid parameters | | `authentication_error` | `authentication_error` | 401 | Invalid/missing API key | | `permission_denied` | `permission_error` | 403 | Insufficient permissions | | `not_found` | `not_found_error` | 404 | Resource not found | | `document_not_found` | `not_found_error` | 404 | Product document not indexed | | `server_error` | `server_error` | 500 | Internal server error | | `service_unavailable` | `server_error` | 503 | Service temporarily unavailable | ### Error Handling Pattern ```typescript import { SmartLinksAIError } from '@proveanything/smartlinks'; async function robustChat() { try { const response = await ai.public.chat('my-collection', { productId: 'coffee-maker', userId: 'user-123', message: 'Help!' }); return response.message; } catch (error) { if (error instanceof SmartLinksAIError) { switch (error.code) { case 'rate_limit_exceeded': console.error('Rate limit exceeded'); console.log('Try again at:', new Date(error.resetAt!)); break; case 'document_not_found': console.error('Product manual not indexed yet'); break; case 'authentication_error': console.error('Invalid API key'); break; case 'invalid_request': console.error('Invalid request:', error.message); break; default: console.error('API error:', error.message); } } else { console.error('Unexpected error:', error); } throw error; } } ``` ### Rate Limit Retry ```typescript async function chatWithRetry( request: PublicChatRequest, maxRetries = 3 ) { let retries = 0; while (retries < maxRetries) { try { return await ai.public.chat('my-collection', request); } catch (error) { if (error instanceof SmartLinksAIError && error.isRateLimitError()) { if (retries === maxRetries - 1) throw error; const resetTime = new Date(error.resetAt!).getTime(); const waitTime = resetTime - Date.now(); console.log(`Rate limited. Waiting ${waitTime}ms...`); await new Promise(resolve => setTimeout(resolve, waitTime)); retries++; } else { throw error; } } } } ``` --- ## Rate Limiting ### Rate Limit Overview Public endpoints are rate-limited per `userId`: | Endpoint Type | Default Limit | Window | |--------------|---------------|--------| | Public Chat | 20 requests | 1 hour | | Token Generation | 10 requests | 1 hour | | Admin Endpoints | Unlimited* | - | *Admin endpoints use API key authentication and are not rate-limited by default. ### Rate Limit Headers All API responses include rate limit information: ``` X-RateLimit-Limit: 20 X-RateLimit-Remaining: 15 X-RateLimit-Reset: 1707300000000 ``` ### Checking Rate Limit ```typescript // Check rate limit status before making requests const status = await ai.public.getRateLimit('my-collection', 'user-123'); console.log('Used:', status.used); console.log('Remaining:', status.remaining); console.log('Resets at:', new Date(status.resetAt)); if (status.remaining > 0) { // Safe to make request await ai.public.chat(/* ... */); } else { // Show user when they can ask again console.log('Rate limit reached. Try again at:', status.resetAt); } ``` ### Resetting Rate Limits (Admin) ```typescript // Reset rate limit for a specific user await ai.rateLimit.reset('my-collection', 'user-123'); console.log('Rate limit reset for user-123'); ``` --- ## Best Practices ### 1. Choose the Right Model ```typescript // For most new workflows (recommended default) await ai.chat.responses.create('my-collection', { model: 'openai/gpt-5.4', input: 'Create a concise onboarding checklist.' }); // For lower-cost structured or JSON-oriented work await ai.chat.responses.create('my-collection', { model: 'openai/gpt-5-mini', input: 'Return a color palette as JSON.' }); // For fast multimodal or cost-sensitive general use await ai.chat.completions.create('my-collection', { model: 'google/gemini-2.5-flash', messages: [...] }); // When you need the actual available catalog for this collection const available = await ai.models.list('my-collection'); console.log(available.data.map(model => model.id)); ``` ### 2. Use Streaming for Long Responses Improve perceived performance with streaming: ```typescript // Non-streaming: User waits for full response const response = await ai.chat.completions.create('my-collection', { messages: [...] }); // Streaming: User sees progress immediately const stream = await ai.chat.completions.create('my-collection', { stream: true, messages: [...] }); for await (const chunk of stream) { updateUI(chunk.choices[0]?.delta?.content); } ``` ### 3. Maintain Session Context Keep conversations coherent with session IDs: ```typescript // Generate unique session ID per conversation const sessionId = `user-${userId}-product-${productId}`; // All questions in same conversation use same sessionId await ai.public.chat('my-collection', { sessionId, message: 'First question', ... }); await ai.public.chat('my-collection', { sessionId, message: 'Follow-up question', ... }); ``` ### 4. Handle Rate Limits Gracefully Show clear feedback to users: ```typescript try { await ai.public.chat('my-collection', {...}); } catch (error) { if (error instanceof SmartLinksAIError && error.isRateLimitError()) { const resetTime = new Date(error.resetAt!); showNotification( `You've reached your question limit. ` + `Try again at ${resetTime.toLocaleTimeString()}` ); } } ``` ### 5. Optimize Voice UX Provide clear status updates: ```typescript async function voiceAssistant() { try { showStatus('Listening...'); const question = await ai.voice.listen(); showStatus('Processing...'); const answer = await ai.public.chat('my-collection', { message: question, ... }); showStatus('Speaking...'); await ai.voice.speak(answer.message); showStatus('Ready'); } catch (error) { showStatus('Error', error.message); } } ``` ### 6. Chunk Large Documents For better RAG performance, chunk documents appropriately: ```typescript // For technical manuals await ai.rag.indexDocument('my-collection', { productId: 'coffee-maker', documentUrl: '...', chunkSize: 500, // Smaller chunks for precise answers overlap: 50 // Overlap maintains context }); // For narrative content await ai.rag.indexDocument('my-collection', { productId: 'coffee-maker', documentUrl: '...', chunkSize: 1000, // Larger chunks for coherent responses overlap: 100 }); ``` ### 7. Use System Prompts Effectively Provide clear instructions: ```typescript await ai.chat.completions.create('my-collection', { messages: [ { role: 'system', content: `You are a coffee maker expert assistant. - Be concise and clear - Use numbered lists for steps - Always mention safety precautions - If unsure, ask for clarification` }, { role: 'user', content: 'How do I descale?' } ] }); ``` ### 8. Monitor Usage and Costs Track usage for cost management: ```typescript const response = await ai.chat.completions.create('my-collection', { messages: [...] }); // Log token usage console.log('Usage:', response.usage); console.log('Prompt tokens:', response.usage.prompt_tokens); console.log('Completion tokens:', response.usage.completion_tokens); console.log('Total tokens:', response.usage.total_tokens); // Calculate estimated cost const model = await ai.models.get('my-collection', response.model); const cost = (response.usage.prompt_tokens * model.pricing.input / 1_000_000) + (response.usage.completion_tokens * model.pricing.output / 1_000_000); console.log('Estimated cost: $', cost.toFixed(4)); ``` --- ## Providing Content to the AI Assistant The portal's built-in AI assistant can discuss the content currently visible to the user. To make this work, your app must supply contextual content when the assistant requests it. There are three extraction methods, tried in priority order: | Priority | Method | When Used | |----------|--------|-----------| | 1 | **Direct prop callback** | Container/widget rendered in the parent React context | | 2 | **PostMessage protocol** | App rendered in an iframe | | 3 | **DOM text fallback** | Neither of the above responded | This section covers **methods 1 and 2** — the ones you implement in your app. ### Method 1: Direct Prop (`onRequestAIContent`) When your app is rendered as a **container** (a direct component in the parent's React tree), the framework passes an `onRequestAIContent` prop to your exported component. You do **not** call this prop yourself — the framework calls it when the AI assistant needs context. Structure your component to make current state accessible when the callback fires: ```tsx import { useEffect, useRef } from 'react'; export function PublicContainer(props) { const { onRequestAIContent, appId, ...rest } = props; // Keep a ref to the latest content so the callback always returns fresh data const currentContentRef = useRef(null); useEffect(() => { currentContentRef.current = buildCurrentContent(); }, [relevantState]); return
{/* your UI */}
; } ``` The framework registers the content provider internally via `useAIContentExtraction.registerContentProvider()`. Your component just needs to respond when the callback is invoked. ### Method 2: PostMessage Protocol (Iframes) For iframe-embedded apps the framework sends a `postMessage` request and expects a response within **500 ms**. If your app doesn't reply in time the framework falls back to DOM text extraction. **Request sent by the framework to your iframe:** ```typescript { type: 'smartlinks:request-ai-content', requestId: 'ai-content-1709834567890-abc123' // unique per request } ``` **Response your app must send back:** ```typescript { type: 'smartlinks:ai-content-response', requestId: 'ai-content-1709834567890-abc123', // echo back the requestId content: AIContentResponse } ``` **Minimal implementation:** ```typescript window.addEventListener('message', async (event) => { if (event.data?.type === 'smartlinks:request-ai-content') { const content = await gatherAIContent(); window.parent.postMessage({ type: 'smartlinks:ai-content-response', requestId: event.data.requestId, content, }, '*'); } }); async function gatherAIContent(): Promise { return { text: 'The user is viewing the warranty registration form...', contentLabel: 'Warranty Registration', }; } ``` ### The `AIContentResponse` Interface ```typescript interface AIContentResponse { /** * Plain text or markdown for the AI to use as context. * Injected into the system prompt. Max recommended: ~4000 characters. */ text: string; /** * Optional structured metadata (key-value pairs). * Not used directly in prompts but available for custom providers. */ metadata?: Record; /** * Pre-built RAG configuration. When provided, the assistant can use * SL.ai.public.chat() to ground answers in indexed product documents. */ ragHint?: { /** The product ID whose indexed documents should be queried */ productId: string; /** Optional session ID for multi-turn RAG conversations */ sessionId?: string; /** Optional context hint to help scope the RAG query */ context?: string; }; /** * Human-readable label shown in context-update messages * (e.g. "Product Manual", "FAQ"). */ contentLabel?: string; /** * How the assistant should use this content: * - 'context' (default): inject text into the system prompt * - 'rag': use ragHint to query indexed docs via SL.ai.public.chat() * - 'hybrid': inject text as context AND ground answers via RAG */ strategy?: 'context' | 'rag' | 'hybrid'; } ``` ### Response Strategies #### `context` (Default) Return readable text. The assistant injects it into the system prompt as background knowledge. Good for descriptions, summaries, structured data, and FAQs. ```typescript return { text: ` ## Wine Details - **Name**: 2023 Château Margaux - **Region**: Bordeaux, France - **Tasting Notes**: Dark fruit, cedar, tobacco - **Food Pairing**: Lamb, aged cheese `, contentLabel: 'Wine Information', strategy: 'context', }; ``` #### `rag` Tell the assistant to query pre-indexed documents via SmartLinks RAG. The `text` field is minimal — the real knowledge comes from the indexed docs. Good for product manuals, large document sets, and technical specs. ```typescript return { text: 'The user is viewing the espresso machine product page.', contentLabel: 'Product Assistant', strategy: 'rag', ragHint: { productId: 'espresso-machine-pro', sessionId: `rag-session-${userId}`, context: 'User is on the troubleshooting section', }, }; ``` When the assistant receives a `rag` strategy it routes the question through `SL.ai.public.chat()`: ```typescript const response = await SL.ai.public.chat(collectionId, { productId: ragHint.productId, userId: currentUserId, message: userQuestion, sessionId: ragHint.sessionId, }); ``` #### `hybrid` Combines both — the `text` is injected as additional context **and** the user's questions are also grounded via RAG. Good for museum exhibits, guided experiences, or any scenario with rich metadata alongside large document sets. ```typescript return { text: ` ## Exhibit: The Starry Night - **Artist**: Vincent van Gogh - **Year**: 1889 - **Current Location**: Gallery 3, East Wing - **Audio Guide**: Available in 12 languages `, contentLabel: 'Museum Exhibit', strategy: 'hybrid', ragHint: { productId: 'starry-night-exhibit', context: 'Art history and technique questions', }, }; ``` ### Content Extraction Examples #### Museum Guide App ```typescript async function gatherAIContent(): Promise { const exhibit = getCurrentExhibit(); return { text: ` Exhibit: "${exhibit.title}" by ${exhibit.artist} Period: ${exhibit.period} Medium: ${exhibit.medium} Description: ${exhibit.curatorNotes} Related works in this gallery: ${exhibit.relatedWorks.join(', ')} `.trim(), contentLabel: `Exhibit: ${exhibit.title}`, strategy: 'hybrid', ragHint: { productId: exhibit.smartlinksProductId, context: `Art history, technique, and visitor information for ${exhibit.title}`, }, metadata: { exhibitId: exhibit.id, gallery: exhibit.gallery, audioGuideAvailable: exhibit.hasAudioGuide, }, }; } ``` #### Wine Product App ```typescript async function gatherAIContent(): Promise { const wine = getCurrentWine(); const reviews = await fetchRecentReviews(wine.id, 5); return { text: ` Wine: ${wine.name} (${wine.vintage}) Winery: ${wine.winery} Region: ${wine.region}, ${wine.country} Grape: ${wine.grape} ABV: ${wine.abv}% Price: ${wine.price} Tasting Notes: ${wine.tastingNotes} Recent Reviews: ${reviews.map(r => `- "${r.text}" (${r.rating}/5)`).join('\n')} `.trim(), contentLabel: 'Wine Details', strategy: 'context', }; } ``` #### Equipment Manual App (RAG-only) ```typescript async function gatherAIContent(): Promise { const equipment = getCurrentEquipment(); return { text: `User is viewing: ${equipment.name} (Model: ${equipment.modelNumber})`, contentLabel: `${equipment.name} Assistant`, strategy: 'rag', ragHint: { productId: equipment.smartlinksProductId, sessionId: `manual-${equipment.id}-${Date.now()}`, context: 'Technical manual, troubleshooting, and maintenance', }, }; } ``` ### Timing & Lifecycle - **On navigation**: When the user navigates to a new product/proof/app, the assistant automatically requests fresh content and injects a context-update system message. - **On first message**: If no content has been gathered yet, the assistant requests it before the first AI call. - **On manual refresh**: The assistant can re-request content at any time (e.g. if the user's view within the app has changed). Content is requested **lazily** — your handler is only called when the AI assistant is active and needs context. If the user never opens the assistant, your handler is never called. ### Method 3: DOM Fallback (Automatic) If your app doesn't implement either of the above, the framework extracts `innerText` from the DOM element with `data-app-container="{appId}"`, truncated to ~4000 characters. This is a **last resort** — content quality is much lower than a structured response. Implementing Method 1 or 2 is strongly recommended. --- ## Related Documentation - [API Summary](./API_SUMMARY.md) - Complete API reference - [Widgets](./widgets.md) - Embedding SmartLinks components - [Realtime](./realtime.md) - Realtime data updates - [iframe Responder](./iframe-responder.md) - iframe integration --- ## Support For questions or issues: - **Documentation:** https://smartlinks.app/docs - **GitHub:** https://github.com/Prove-Anything/smartlinks - **Email:** support@smartlinks.app --- # SDK guide: Liquid Templates Source: https://docs.smartlinks.app/docs/sdk/guides/liquid-templates Dynamic content rendering with Liquid templating for emails, notifications, and personalised content. # Liquid Templates in SmartLinks Liquid is a templating language that allows you to dynamically insert data into text content. SmartLinks uses Liquid Templates in various APIs—such as email templates, notification messages, and dynamic content—to personalize communications with real-time data from your collections, products, proofs, and users. --- ## What are Liquid Templates? Liquid is an open-source template language created by Shopify. It uses a simple syntax with two main components: - **Output tags** `{{ }}` — Insert dynamic values - **Logic tags** `{% %}` — Control flow (if/else, loops, etc.) ### Basic Example ```liquid Hello {{ contact.name }}, Thank you for registering your {{ product.name }}! Your proof ID is: {{ proof.id }} {% if proof.claimed %} This item was claimed on {{ proof.claimedAt | date: "%B %d, %Y" }}. {% endif %} ``` --- ## Core Data Objects SmartLinks provides several core objects that can be accessed in Liquid Templates. The available objects depend on the context (e.g., a proof-level template has access to `proof`, `product`, and `collection`). --- ### Collection A **Collection** represents a top-level business, brand, or organization. All products belong to a collection. | Field | Type | Description | |-------|------|-------------| | `collection.id` | string | Unique identifier | | `collection.title` | string | Display title of the collection | | `collection.description` | string | Description text | | `collection.shortId` | string | Short identifier for the collection | | `collection.logoImage.url` | string | URL to the collection's logo image | | `collection.logoImage.thumbnails.x100` | string | 100px thumbnail | | `collection.logoImage.thumbnails.x200` | string | 200px thumbnail | | `collection.logoImage.thumbnails.x512` | string | 512px thumbnail | | `collection.headerImage.url` | string | URL to collection header/hero image | | `collection.headerImage.thumbnails.*` | string | Header image thumbnails (x100, x200, x512) | | `collection.loaderImage.url` | string | URL to collection loader image | | `collection.primaryColor` | string | Primary theme color (hex code) | | `collection.secondaryColor` | string | Secondary theme color (hex code) | | `collection.dark` | boolean | Whether dark mode is enabled | | `collection.portalUrl` | string | URL for the collection's portal | | `collection.redirectUrl` | string | Custom domain redirect URL | | `collection.roles` | object | User roles mapping (userId → role) | | `collection.groupTags` | array | Array of group tag names | | `collection.languages` | array | Array of supported language objects | | `collection.defaultAuthKitId` | string | Default auth kit ID | | `collection.allowAutoGenerateClaims` | boolean | Allow claiming without proof ID | #### Example Usage ```liquid Welcome to {{ collection.title }}! {% if collection.portalUrl %} Visit our portal at {{ collection.portalUrl }} {% endif %} {% if collection.logoImage %} {{ collection.title }} logo {{ collection.title }} logo {% endif %} {% if collection.dark %} {% endif %} ``` --- ### Product A **Product** represents a type or definition of a physical or digital item. Products belong to a collection and can have many proofs (instances). | Field | Type | Description | |-------|------|-------------| | `product.id` | string | Unique identifier | | `product.name` | string | Product name | | `product.collectionId` | string | ID of the parent collection | | `product.description` | string | Product description | | `product.gtin` | string | Global Trade Item Number | | `product.type` | string | Product type from standard types | | `product.heroImage.url` | string | Primary product image URL | | `product.heroImage.thumbnails.x100` | string | 100px thumbnail | | `product.heroImage.thumbnails.x200` | string | 200px thumbnail | | `product.heroImage.thumbnails.x512` | string | 512px thumbnail | | `product.tags` | object | Tag map with boolean values | | `product.data` | object | Flexible key-value data map | | `product.admin` | object | Admin-only configuration | | `product.admin.allowAutoGenerateClaims` | boolean | Allow claiming without proof ID | | `product.admin.lastSerialId` | number | Last generated serial ID | #### Example Usage ```liquid Your {{ product.name }} {{ product.description }} {% if product.gtin %} GTIN: {{ product.gtin }} {% endif %} {% if product.heroImage %} {{ product.name }} {{ product.name }} {% endif %} {% if product.tags.premium %} 🌟 Premium Product {% endif %} {% if product.data.warranty_years %} Warranty: {{ product.data.warranty_years }} years {% endif %} ``` --- ### Proof A **Proof** is a specific instance of a product—think of it as a unique digital certificate for a physical item. Proofs can be claimed by users and carry ownership information. | Field | Type | Description | |-------|------|-------------| | `proof.id` | string | Unique identifier | | `proof.collectionId` | string | ID of the parent collection | | `proof.productId` | string | ID of the associated product | | `proof.tokenId` | string | Unique token identifier | | `proof.userId` | string | User ID of the owner | | `proof.claimable` | boolean | Whether the proof can be claimed | | `proof.virtual` | boolean | Whether this is a virtual proof | | `proof.values` | object | Arbitrary key-value pairs for proof data | | `proof.createdAt` | datetime | When the proof was created | **Note**: Proof `values` object can contain any custom fields. Common examples: - `proof.values.serialNumber` - Serial number - `proof.values.claimedAt` - Claim timestamp - `proof.values.status` - Current status - `proof.values.warrantyExpiry` - Warranty expiration #### Example Usage ```liquid Proof of Authenticity {% if proof.values.serialNumber %} Serial Number: {{ proof.values.serialNumber }} {% endif %} {% if proof.values.status %} Status: {{ proof.values.status }} {% endif %} {% if proof.claimable %} This item is available to claim. {% else %} This item has been claimed. {% endif %} {% if proof.virtual %} 🌐 Digital Product {% endif %} {% if proof.values.claimedAt %} Claimed on: {{ proof.values.claimedAt | date: "%B %d, %Y at %H:%M" }} {% endif %} {% if proof.values.warrantyExpiry %} Warranty expires: {{ proof.values.warrantyExpiry | date: "%B %d, %Y" }} {% endif %} ``` --- ### Contact A **Contact** represents a customer or user in the system. Contacts are associated with a collection and can own multiple proofs. | Field | Type | Description | |-------|------|-------------| | `contact.contactId` | string | Unique identifier | | `contact.orgId` | string | Organization/collection ID | | `contact.userId` | string | Linked user ID (if authenticated) | | `contact.email` | string | Primary email address | | `contact.phone` | string | Primary phone number | | `contact.emails` | array | Array of all email addresses | | `contact.phones` | array | Array of all phone numbers | | `contact.firstName` | string | First name | | `contact.lastName` | string | Last name | | `contact.displayName` | string | Display name | | `contact.company` | string | Company name | | `contact.avatarUrl` | string | Profile picture URL | | `contact.locale` | string | Preferred language/locale (e.g., "en", "de") | | `contact.timezone` | string | Preferred timezone | | `contact.tags` | array | Array of tag strings for segmentation | | `contact.source` | string | How the contact was created | | `contact.notes` | string | Admin notes | | `contact.externalIds` | object | External system IDs | | `contact.customFields` | object | Custom key-value data | | `contact.createdAt` | datetime | When the contact was created | | `contact.updatedAt` | datetime | When the contact was last updated | #### Example Usage ```liquid Hi {{ contact.firstName | default: contact.displayName | default: "there" }}, {% if contact.locale == "de" %} Willkommen! {% elsif contact.locale == "fr" %} Bienvenue! {% else %} Welcome! {% endif %} {% if contact.phone %} We'll send updates to {{ contact.phone }}. {% endif %} {% if contact.company %} Company: {{ contact.company }} {% endif %} {% if contact.customFields.vip %} 🌟 VIP Customer {% endif %} ``` --- ### User (Account) A **User** represents an authenticated account in the system. This is typically the logged-in user performing an action. | Field | Type | Description | |-------|------|-------------| | `user.uid` | string | Unique identifier | | `user.email` | string | Email address | | `user.displayName` | string | Display name | | `user.accountData` | object | Account-specific data and settings | #### Example Usage ```liquid Logged in as: {{ user.displayName }} ({{ user.email }}) {% if user.accountData.preferences.notifications %} Notifications are enabled. {% endif %} ``` --- ### Attestation An **Attestation** is flexible data attached to a specific proof. It's used to store additional information like warranty registrations, tasting notes, service records, etc. | Field | Type | Description | |-------|------|-------------| | `attestation.id` | string | Unique identifier | | `attestation.public` | object | Public attestation data (varies by type) | | `attestation.private` | object | Private attestation data (varies by type) | | `attestation.proof` | object | Associated proof reference/data | | `attestation.createdAt` | datetime | When the attestation was created | | `attestation.updatedAt` | datetime | When the attestation was last updated | **Note**: The `public` and `private` objects contain custom fields based on your use case. #### Example Usage ```liquid {% if attestation.public.type == "warranty_registration" %} Warranty Registration Details: - Registered: {{ attestation.createdAt | date: "%B %d, %Y" }} - Purchase Date: {{ attestation.public.purchaseDate }} - Store: {{ attestation.public.storeName }} {% endif %} {% if attestation.public.type == "tasting_note" %} 🍷 Tasting Note: "{{ attestation.public.notes }}" Rating: {{ attestation.public.rating }}/5 {% endif %} {% if attestation.private.internalNotes %} Notes: {{ attestation.private.internalNotes }} {% endif %} ``` --- ## Liquid Filters Liquid provides built-in filters to transform data. Common filters include: ### Text Filters | Filter | Description | Example | |--------|-------------|---------| | `upcase` | Convert to uppercase | `{{ product.name \| upcase }}` | | `downcase` | Convert to lowercase | `{{ product.name \| downcase }}` | | `capitalize` | Capitalize first letter | `{{ contact.name \| capitalize }}` | | `truncate` | Limit string length | `{{ product.description \| truncate: 100 }}` | | `strip_html` | Remove HTML tags | `{{ content \| strip_html }}` | | `escape` | HTML escape special chars | `{{ user_input \| escape }}` | | `default` | Fallback value if empty | `{{ contact.name \| default: "Customer" }}` | ### Date Filters | Filter | Description | Example | |--------|-------------|---------| | `date` | Format a date | `{{ proof.claimedAt \| date: "%B %d, %Y" }}` | Common date formats: - `%B %d, %Y` → January 15, 2025 - `%Y-%m-%d` → 2025-01-15 - `%d/%m/%Y` → 15/01/2025 - `%H:%M` → 14:30 ### Array Filters | Filter | Description | Example | |--------|-------------|---------| | `join` | Join array elements | `{{ product.tags \| join: ", " }}` | | `first` | Get first element | `{{ product.images \| first }}` | | `last` | Get last element | `{{ product.images \| last }}` | | `size` | Get array length | `{{ product.tags.size }}` | | `sort` | Sort array | `{{ items \| sort: "name" }}` | ### Number Filters | Filter | Description | Example | |--------|-------------|---------| | `plus` | Add | `{{ count \| plus: 1 }}` | | `minus` | Subtract | `{{ total \| minus: discount }}` | | `times` | Multiply | `{{ price \| times: quantity }}` | | `divided_by` | Divide | `{{ total \| divided_by: 2 }}` | | `round` | Round number | `{{ average \| round: 2 }}` | --- ## Control Flow ### Conditionals ```liquid {% if proof.claimed %} This item is claimed. {% elsif proof.status == "pending" %} Claim pending verification. {% else %} Available to claim. {% endif %} {% unless contact.email %} No email on file. {% endunless %} ``` ### Operators | Operator | Description | |----------|-------------| | `==` | Equals | | `!=` | Not equals | | `>` | Greater than | | `<` | Less than | | `>=` | Greater than or equal | | `<=` | Less than or equal | | `or` | Logical OR | | `and` | Logical AND | | `contains` | String/array contains | ```liquid {% if product.tags contains "premium" %} 🌟 Premium Product {% endif %} {% if contact.email and proof.claimed %} Send confirmation to {{ contact.email }} {% endif %} ``` ### Loops ```liquid {% for tag in product.tags %} {{ tag }} {% endfor %} {% for image in product.images limit: 3 %} {{ product.name }} image {{ forloop.index }} {% endfor %} ``` Loop variables: - `forloop.index` — Current iteration (1-indexed) - `forloop.index0` — Current iteration (0-indexed) - `forloop.first` — Is this the first iteration? - `forloop.last` — Is this the last iteration? - `forloop.length` — Total number of iterations --- ## Common Use Cases ### Email Templates ```liquid Subject: Your {{ product.name }} has been registered! Hi {{ contact.firstName | default: contact.displayName | default: "there" }}, Great news! Your {{ product.name }}{% if proof.values.serialNumber %} (Serial: {{ proof.values.serialNumber }}){% endif %} has been successfully registered to your account. {% if product.data.warranty_years %} Your warranty is valid for {{ product.data.warranty_years }} years from the date of purchase. {% endif %} If you have any questions, please contact {{ collection.title }} support. Best regards, The {{ collection.title }} Team ``` ### Notification Messages ```liquid 🎉 {{ contact.firstName }}, your {{ product.name }} is now verified! {% if proof.values.shortCode %}Proof ID: {{ proof.values.shortCode }}{% endif %} ``` ### Dynamic Content Blocks ```liquid {% if proof.values.tier == "gold" %}
As a Gold member, you get exclusive access to...
{% elsif proof.values.tier == "silver" %}
Your Silver membership includes...
{% endif %} ``` ### Multilingual Content ```liquid {% case contact.locale %} {% when "de" %} Vielen Dank für Ihre Registrierung! {% when "fr" %} Merci pour votre inscription! {% when "es" %} ¡Gracias por registrarte! {% else %} Thank you for registering! {% endcase %} ``` --- ## Accessing Nested Data Use dot notation to access nested fields in data objects: ```liquid {{ product.data.manufacturer }} {{ attestation.public.warranty.expiryDate }} {{ contact.customFields.vip_level }} {{ proof.values.serialNumber }} ``` For dynamic keys, you may need to use bracket notation (if supported): ```liquid {{ product.data["custom-field"] }} ``` --- ## Best Practices 1. **Always use `default` filter** for optional fields to avoid blank output: ```liquid {{ contact.displayName | default: contact.firstName | default: "Valued Customer" }} ``` 2. **Escape user-generated content** when outputting as HTML: ```liquid {{ attestation.public.userNotes | escape }} ``` 3. **Check for existence** before accessing nested data: ```liquid {% if proof.values.warranty %} Warranty: {{ proof.values.warranty.type }} {% endif %} ``` 4. **Use meaningful fallbacks** for a better user experience: ```liquid Hi {{ contact.firstName | default: contact.displayName | default: "there" }}, ``` 5. **Format dates appropriately** for the user's locale: ```liquid {{ proof.createdAt | date: "%d %B %Y" }} ``` --- ## API Context Different APIs provide different objects in the Liquid context: | API / Feature | Available Objects | |---------------|-------------------| | Email Templates | `collection`, `product`, `proof`, `contact`, `attestation` | | Push Notifications | `collection`, `product`, `proof`, `contact` | | SMS Messages | `collection`, `product`, `proof`, `contact` | | Wallet Passes | `collection`, `product`, `proof`, `contact` | | Journey Actions | `collection`, `product`, `proof`, `contact`, `event` | | Broadcast Campaigns | `collection`, `contact`, `segment` | Check the specific API documentation for the exact objects available in each context. --- ## Further Resources - [Liquid Template Language Documentation](https://shopify.github.io/liquid/) - [Liquid for Designers](https://github.com/Shopify/liquid/wiki/Liquid-for-Designers) - SmartLinks Template API Reference --- # SDK guide: Microapp Development Overview Source: https://docs.smartlinks.app/docs/sdk/guides/overview Platform overview, SDK minimums, and how to build SmartLinks microapps. # SmartLinks Microapp Development Guide > **Platform revision:** R4.6 · **SDK minimum:** `@proveanything/smartlinks@1.4.1` > **Last updated:** 2026-03-03 --- ## What Is a SmartLinks Microapp? SmartLinks microapps are **modular, embeddable React applications** that extend the SmartLinks digital twin platform. They provide specialised functionality — product information displays, warranty registration, competitions, pamphlet generators, and more — while inheriting context, authentication, and theming from the parent platform. ### The Digital Twin Ecosystem SmartLinks is a **digital twin platform** that connects physical products to digital experiences. Each physical item (a wine bottle, a luxury handbag, a piece of equipment) has a corresponding digital identity — a "proof" — that can be scanned, claimed, and enriched with data over time. Microapps are the **extensibility layer** of this ecosystem. Rather than building monolithic features into the core platform, functionality is distributed across purpose-built apps that: - **Embed seamlessly** via iframes in the SmartLinks Portal (public) and Admin Console (management) - **Share context** through URL parameters (collection, product, or proof being viewed) - **Inherit identity** from the parent platform's authentication system - **Adapt visually** to the brand's theme configuration - **Communicate bidirectionally** with the parent via postMessage for deep linking and navigation ### Deployment Modes Each microapp can be consumed in one or more of the following ways: | Mode | Description | |------|-------------| | **Container** | Full app in parent React tree. ~150 KB+ lazy-loaded. The primary consumer surface. | | **Iframe App** | Full React app inside an iframe. Fallback when sandboxing is required; still the standard for setup admin screens. | | **Widget** | Lightweight React component in parent tree. ~10 KB, loaded immediately alongside the page. | | **Mobile Admin Container** | Separate bundle for in-the-field operator/admin workflows on mobile. Built independently; may use Capacitor, Preact, or any other runtime. | | **Executor** | JS library, no UI. Programmatic config, SEO metadata, LLM content for AI/server. | Widgets and containers run in the parent's React tree (not iframes). Mobile Admin Containers are a separate bundle loaded by a dedicated mobile shell (Capacitor or PWA). Executors have no UI — they expose functions that AI orchestrators and the server call directly. > **Admin vs consumer:** Admin experiences always ship as a **separate bundle** (`mobileAdmin` or iframe). The `containers` bundle is for consumer-facing surfaces only. --- ## SDK Documentation Reference The SmartLinks SDK (`@proveanything/smartlinks`) includes comprehensive documentation in `node_modules/@proveanything/smartlinks/docs/`. **Always read these files for detailed implementation guidance.** > **Minimum SDK version: `1.4.1`** — Ensure `@proveanything/smartlinks` is at least this version. If not, update with `npm install @proveanything/smartlinks@latest`. | Topic | File | When to Use | |-------|------|-------------| | **API Reference** | `docs/API_SUMMARY.md` | Complete SDK function reference, types, error handling | | **Building React Components** | `docs/building-react-components.md` | **READ THIS FIRST** — Dual-mode rendering, router rules, useAppContext pattern | | **Multi-Page Architecture** | `docs/mpa.md` | Build pipeline, entry points, multi-page setup, content hashing | | **AI & Chat** | `docs/ai.md` | Chat completions, RAG, streaming, tool calling, voice, podcasts, TTS | | **Analytics** | `docs/analytics.md` | Fire-and-forget page/click/tag analytics plus admin dashboard queries | | **Translations** | `docs/translations.md` | Runtime translation lookup, local-first IndexedDB caching, and translation admin APIs | | **Theming** | `docs/theme.system.md` | Implementing dynamic themes via URL params or postMessage | | **Theme Defaults** | `docs/theme-defaults.md` | Default colour values for light/dark modes | | **Internationalization** | `docs/i18n.md` | Adding multi-language support, translation patterns | | **Widgets** | `docs/widgets.md` | Building widgets, shared deps contract, settings schema | | **Containers** | `docs/containers.md` | Building full-app embeddable containers (lazy-loaded) | | **Mobile Admin Container** | `docs/mobile-admin-container.md` | Building a separate Capacitor-aware mobile admin bundle for field operators | | **Executors** | `docs/executor.md` | Building executor bundles for SEO, LLM content, programmatic config | | **Deep Linking** | `docs/deep-link-discovery.md` | URL state management, navigable states, portal menus, AI nav | | **Interactions** | `docs/interactions.md` | Business events, outcomes, voting, competitions, and journey triggers | | **AI-Native Manifests** | `docs/manifests.md` | `app.manifest.json`, `app.admin.json`, `ai-guide.md` structure | | **App Config Files** | `docs/app-manifest.md` | Full field-by-field reference for both JSON config files | | **Real-time Messaging** | `docs/realtime.md` | Adding Ably real-time features (chat, live updates) | | **Liquid Templates** | `docs/liquid-templates.md` | Dynamic content rendering with LiquidJS | | **Iframe Responder** | `docs/iframe-responder.md` | Iframe communication and proxy mode internals | | **AI Guide Template** | `docs/ai-guide-template.md` | Template for creating `public/ai-guide.md` — customise per app | | **Forms** | `docs/forms.md` | Form definitions, schema-driven rendering, submission patterns | | **Auth Kit** | `docs/auth-kit.md` | End-user sign-in: email/password, magic links, phone OTP, Google OAuth | | **App Records Pattern** | `docs/app-records-pattern.md` | Standard pattern for per-product/facet/variant/batch admin + public widget UIs | | **UI Utils** | `docs/ui-utils.md` | `@proveanything/smartlinks-utils-ui` — React shells, hooks, and primitives for records-based apps | --- ## Technical Overview - **Iframe-based** — Apps run inside iframes in both the public Portal and Admin Console - **Hash routing** — Uses `/#/` routes for iframe compatibility (e.g., `/#/`, `/#/admin`) - **Context via URL params** — All contextual data is passed through URL parameters - **SmartLinks NPM module** — All data access and platform interaction goes through `@proveanything/smartlinks` - **No standalone auth** — Authentication is handled by the parent SmartLinks platform - **Multi-page build** — Separate bundles for public and admin — see `docs/mpa.md` --- ## Data Model Hierarchy ``` Collection (Business/Brand) └── Product (Product Type/Definition) └── Proof (Specific Instance with Owner) └── Attestation (User/Admin Data on Proof) ``` | Entity | Description | Example | |--------|-------------|---------| | **Collection** | Top-level container representing a business or brand | "Acme Wine Co." | | **Product** | A type of product (not a specific item) | "2023 Cabernet Sauvignon" | | **Proof** | A specific instance of a product, often with an owner | Bottle #12345 claimed by user@email.com | | **Attestation** | Data attached to a proof by users or admins | Warranty registration, tasting notes | ### URL Parameters | Parameter | Type | Description | |-----------|------|-------------| | `collectionId` | string | The collection being viewed/managed | | `productId` | string | The specific product (within collection) | | `proofId` | string | The specific proof instance | | `appId` | string | This app's unique identifier (for data scoping) | | `dark` | `1` or `0` | Whether to use dark mode | | `theme` | base64 | Full theme configuration (see `docs/theme.system.md`) | --- ## Authentication & Permissions SmartLinks apps do **not** implement their own authentication. The parent platform handles all auth: - Use `SL.auth.getAccount()` to check if a user is logged in - The response includes whether the user is an `admin` or regular user - Many SmartLinks API functions behave differently based on admin status | Capability | Admin | Regular User | |------------|-------|-------------| | Read collection/product data | ✅ | ✅ | | Write app configuration | ✅ | ❌ | | Write attestations | ✅ | ✅ (own proofs only) | | Access admin-only API fields | ✅ | ❌ | --- ## Data Storage Patterns ### ⚠️ Critical: Admin Mode Flag **When building admin interfaces, you MUST pass `admin: true` in API options.** Without this flag, API calls use the public endpoint and will fail to write data: ```typescript // ❌ WRONG — will fail in admin interface await SL.appConfiguration.setConfig({ collectionId, appId, config: myConfig }); // ✅ CORRECT — include admin: true await SL.appConfiguration.setConfig({ collectionId, appId, config: myConfig, admin: true }); ``` This applies to all write operations: `setConfig`, `setDataItem`, `updateDataItem`, etc. ### Endpoint Auth vs Data Visibility `admin: true` is an endpoint selector, not a privacy marker. - It tells the SDK to call the admin endpoint. - It allows writes and admin reads. - It does **not** mean every root-level field you save becomes admin-only. For `appConfiguration` config blobs and `collection.getSettings()` groups, root-level fields are typically the public-facing settings. If you need private values such as tokens or secrets, store them inside a top-level `admin` object: ```typescript await SL.appConfiguration.setConfig({ collectionId, appId, admin: true, config: { publicLabel: 'Warranty Portal', color: '#B68C2A', admin: { accessToken: 'secret-token' } } }) ``` Public reads omit the `admin` block. Admin reads include it. ### Config vs Data | Storage Type | Function | Use Case | |-------------|---------|---------| | **Config** (`getConfig`/`setConfig`) | Single JSON document | App settings, feature flags, global options | | **Data** (`getData`/`setDataItem`) | Array of documents with IDs | Lists of items, records, entries | | **App Objects** (`app.records` / `app.cases` / `app.threads`) | Queryable domain objects | Real app entities, workflows, conversations, richer access control | Both can be scoped to **collection level** or **product level** by including `productId`. For config/settings visibility, remember: root fields are the normal shared payload, while a reserved top-level `admin` object is the place for admin-only values. Prefer `app.records` over `setDataItem` when the data is becoming a real entity that needs lifecycle, ownership, visibility, relationships, or filtering. Keep `setDataItem` for simple keyed scoped documents and config-adjacent content. ### Attestations (Proof-level data) For data attached to specific proof instances, use `SL.attestation.create()` and `SL.attestation.list()`. --- ## Error Handling The SmartLinks SDK returns structured `SmartlinksApiError` objects. Use `instanceof SmartlinksApiError` to access `.statusCode`, `.message`, and helper methods: - `.isAuthError()` — 401/403 responses - `.isNotFound()` — 404 responses - `.isRateLimited()` — 429 responses - `.isServerError()` — 5xx responses See `docs/API_SUMMARY.md` for complete error handling documentation. --- ## AI Integration SmartLinks provides comprehensive AI capabilities through the `SL.ai` namespace. **Always use SmartLinks AI for microapps** — do not integrate external AI services (OpenAI, Anthropic, Lovable Cloud AI, etc.) directly. Key capabilities: chat completions (streaming + tool calling), RAG document Q&A, voice input/output, podcast generation, text-to-speech, model listing. See `docs/ai.md` for complete documentation. --- ## Interactions & Event Tracking The `SL.interactions` namespace tracks user engagement — competition entries, votes, form submissions, warranty registrations. Events can trigger automated journeys and communications. Key functions: `submitPublicEvent()`, `appendEvent()` (admin), `countsByOutcome()`, `query()`. See `docs/interactions.md` for complete documentation. --- ## Liquid Templating Use the `` component, `useLiquidTemplate` hook, and `renderLiquid` utility for admin-configured dynamic content using Liquid syntax. See `docs/liquid-templates.md` for full context variables, filters, and examples. --- ## Design Principles ### Public Interface (Portal) - **Mobile-first** — Assume users are on phones - **Non-technical** — Never show raw IDs or technical data - **Accessible** — Follow WCAG guidelines ### Admin Interface - **Feature-rich** — Power users expect advanced controls - **Efficient** — Minimise clicks for common tasks - **Desktop-optimised** — Admins typically use desktop browsers ### Theming Apps receive theme configuration from the parent platform via URL parameter (`?theme=base64…`) and PostMessage updates. See `docs/theme.system.md`. --- ## Common Hooks ```typescript // Fetch collection/product/proof data useCollectionData(collectionId) useProductData(collectionId, productId) useProofData(collectionId, productId, proofId) // Fetch app configuration useCollectionAppConfig(collectionId, appId) useProductAppConfig(collectionId, productId, appId) // Fetch app data (array of items) useCollectionAppData(collectionId, appId) useProductAppData(collectionId, productId, appId) // Fetch attestations useAttestationsData(collectionId, productId, proofId) // URL parameter management usePersistentQueryParams() // Theme management useSmartLinksTheme() // Liquid templating useLiquidTemplate(templateString, contextData) ``` --- ## API Namespaces The SmartLinks SDK is organised into namespaces. See `docs/API_SUMMARY.md` for the complete reference. Key namespaces: - `appConfiguration` — Config and data storage (most apps use this) - `attestation` — Proof-level user/admin data - `interactions` — Event tracking and analytics - `ai` — AI content generation, chat, RAG (see `docs/ai.md`) - `auth` / `authKit` — Admin and end-user authentication - `contact` — Customer contact management - `comms` / `broadcasts` — Notifications and campaigns - `asset` — File/image uploads - `realtime` — Ably real-time messaging (see `docs/realtime.md`) --- ## File Structure ``` ├── index.html ← Public portal entry ├── admin.html ← Admin console entry ├── public/ │ ├── app.manifest.json ← Public discovery manifest │ ├── app.admin.json ← Admin manifest (on-demand) │ └── ai-guide.md ← AI orchestrator prose guide ├── src/ │ ├── main.tsx ← Public React entry │ ├── admin-main.tsx ← Admin React entry │ ├── App.tsx ← Shared providers (QueryClient, Toaster, etc.) │ ├── PublicApp.tsx ← Public app shell (routes only public pages) │ ├── AdminApp.tsx ← Admin app shell (routes only admin pages) │ ├── components/ ← Reusable UI components │ ├── containers/ ← Embeddable full-app containers │ ├── executor/ ← Executor bundle (SEO, LLM content, config API) │ ├── hooks/ ← React hooks (data fetching, state) │ ├── pages/ │ │ ├── PublicPage.tsx ← Portal content (/#/) │ │ ├── AdminPage.tsx ← Admin content (admin.html#/) │ │ └── DevPage.tsx ← Development helper (dev only) │ ├── widgets/ ← Embeddable widget components │ └── utils/ │ ├── smartlinks/ ← SmartLinks utilities │ └── theme.ts ← Theme utilities ├── vite.config.ts ← Multi-page build config ├── vite.config.widget.ts ← Widget library build config ├── vite.config.container.ts ← Container library build config └── vite.config.executor.ts ← Executor library build config ``` --- ## Anti-Patterns to Avoid ❌ **Don't implement custom auth** — Use SmartLinks auth via `SL.auth.getAccount()` ❌ **Don't use BrowserRouter** — Always use HashRouter for iframe compatibility ❌ **Don't hardcode URLs** — Use relative paths and let the platform provide context ❌ **Don't store secrets in code** — Use environment variables or platform configuration ❌ **Don't assume screen size** — Support both mobile (Portal) and desktop (Admin) ❌ **Don't expose technical IDs** — Show human-readable names to users ❌ **Don't import AdminPage in PublicApp** — Keep bundles separate for optimal size ❌ **Don't use external AI services** — Use SmartLinks AI via `SL.ai`, not OpenAI/Anthropic/Lovable Cloud AI directly ❌ **Don't forget `admin: true`** — All admin write operations require this flag --- ## Quick Reference ### Initialise the API ```typescript import { initializeAPI } from '@/utils/smartlinks/initialization'; initializeAPI(); // Call once at app startup ``` ### Get Context from URL ```typescript const { persistentQueryParams } = usePersistentQueryParams(); const { collectionId, productId, appId } = persistentQueryParams; ``` ### Check Admin Status ```typescript const account = await SL.auth.getAccount(); const isAdmin = account?.admin === true; ``` ### Save Configuration (Admin Only) ```typescript await SL.appConfiguration.setConfig({ collectionId, appId, config: { myKey: 'myValue' }, admin: true // Required! }); ``` ### Handle Dark Mode + Theming ```typescript // In App.tsx — already integrated in the template useDarkMode(); // Handles ?dark=1 parameter useSmartLinksTheme(); // Handles ?theme=base64 parameter useDeepLinkSync(); // Syncs route changes to parent ``` ### Deep Linking ```typescript const { navigateWithState, getAppState } = usePersistentQueryParams(); const { tab = 'general' } = getAppState(); navigateWithState('/', { tab: 'advanced' }); ``` See `docs/deep-link-discovery.md` for full implementation details. --- # SDK guide: iframe Responder Source: https://docs.smartlinks.app/docs/sdk/guides/iframe-responder Parent-side iframe communication with API proxying, auth sync, and deep linking. # IframeResponder - Parent-Side Iframe Communication The `IframeResponder` enables bidirectional communication between a parent application and an embedded iframe, both using the SmartLinks SDK. This allows seamless integration of microapps within your application with automatic API proxying, authentication sync, and deep linking. ## Features - **Automatic URL Resolution** - Fetches app configuration from collection and resolves the correct URL - **API Request Proxying** - Forwards API requests from iframe to parent's authenticated session - **Smart Caching** - Reduces API calls by serving cached collection, product, and proof data - **Authentication Sync** - Handles login/logout events between parent and iframe - **Deep Linking** - Synchronizes iframe routes with parent URL state - **Responsive Sizing** - Calculates and reports optimal iframe dimensions - **Chunked File Uploads** - Proxies large file uploads through the parent ## Basic Usage ### 1. Create and Attach Responder ```typescript import * as smartlinks from '@proveanything/smartlinks'; // Initialize SDK smartlinks.initializeApi({ baseURL: 'https://api.smartlinks.io', apiKey: 'your-api-key', }); // Create responder (URL resolved automatically from collection apps) const responder = new smartlinks.IframeResponder({ collectionId: 'acme-wines', appId: 'warranty-registration', productId: 'wine-bottle-123', // Optional context onAuthLogin: async (token, user) => { // Handle authentication from iframe smartlinks.setBearerToken(token); console.log('User logged in:', user); }, onResize: (height) => { // Update iframe height iframeElement.style.height = `${height}px`; }, }); // Attach to iframe element const iframe = document.getElementById('app-iframe') as HTMLIFrameElement; const src = await responder.attach(iframe); iframe.src = src; // Cleanup when done // responder.destroy(); ``` ### 2. With Pre-cached Data (Faster Loading) ```typescript // Fetch data upfront const collection = await smartlinks.collection.get('acme-wines'); const product = await smartlinks.product.get('wine-bottle-123'); const user = await smartlinks.auth.getAccountInfo(); const responder = new smartlinks.IframeResponder({ collectionId: 'acme-wines', appId: 'warranty-registration', productId: 'wine-bottle-123', // Pre-populate cache for instant API responses cache: { collection, product, user: user ? { uid: user.uid, email: user.email, displayName: user.displayName, accountData: user.accountData, } : null, }, onAuthLogin: async (token, user) => { smartlinks.setBearerToken(token); }, }); const src = await responder.attach(iframeElement); iframeElement.src = src; ``` ## Configuration Options ### IframeResponderOptions ```typescript interface IframeResponderOptions { // Required collectionId: string; // Collection context appId: string; // App to load (e.g., 'warranty-registration') // Optional Context productId?: string; // Product context proofId?: string; // Proof context isAdmin?: boolean; // Admin mode flag // URL Configuration version?: 'stable' | 'development'; // App version (default: 'stable') appUrl?: string; // Override URL (for local dev) initialPath?: string; // Initial hash path (e.g., '/settings') // Data cache?: CachedData; // Pre-cached data // Callbacks onAuthLogin?: (token: string, user: any, accountData?: any) => Promise; onAuthLogout?: () => Promise; onRouteChange?: (path: string, state: Record) => void; onResize?: (height: number) => void; onError?: (error: Error) => void; onReady?: () => void; } ``` ## Advanced Examples ### Deep Linking / Route Synchronization Keep the parent URL in sync with iframe navigation: ```typescript const responder = new smartlinks.IframeResponder({ collectionId: 'acme-wines', appId: 'warranty', initialPath: '/products/wine-123', onRouteChange: (path, state) => { // Update parent URL with iframe route const url = new URL(window.location.href); url.searchParams.set('app-path', path); Object.entries(state).forEach(([key, value]) => { url.searchParams.set(key, value); }); window.history.pushState({}, '', url); }, }); ``` ### Local Development Override ```typescript const responder = new smartlinks.IframeResponder({ collectionId: 'acme-wines', appId: 'warranty', // Override URL for local development appUrl: 'http://localhost:5173', version: 'development', }); ``` ### Admin Mode with Role Detection ```typescript const user = await smartlinks.auth.getAccountInfo(); const collection = await smartlinks.collection.get('acme-wines'); const isAdmin = smartlinks.isAdminFromRoles(user, collection); const responder = new smartlinks.IframeResponder({ collectionId: 'acme-wines', appId: 'warranty', isAdmin, // Pass admin status to iframe cache: { collection, user }, }); ``` ### Responsive Height Management ```typescript const responder = new smartlinks.IframeResponder({ collectionId: 'acme-wines', appId: 'warranty', onResize: (height) => { // Smooth height transitions iframe.style.transition = 'height 0.3s ease'; iframe.style.height = `${height}px`; // Or calculate viewport-based height const maxHeight = window.innerHeight - 100; iframe.style.height = `${Math.min(height, maxHeight)}px`; }, }); ``` ## Helper Functions ### buildIframeSrc() Manually construct an iframe src URL without using the full responder: ```typescript const src = smartlinks.buildIframeSrc({ appUrl: 'https://warranty.lovable.app', collectionId: 'acme-wines', appId: 'warranty', productId: 'wine-123', isAdmin: false, dark: true, theme: { primary: '#FF6B6B', secondary: '#4ECDC4', }, initialPath: '/register', }); iframe.src = src; ``` ### isAdminFromRoles() Check if a user has admin access: ```typescript const user = await smartlinks.auth.getAccountInfo(); const collection = await smartlinks.collection.get('acme-wines'); const isAdmin = smartlinks.isAdminFromRoles(user, collection); // or with proof const isAdminOfProof = smartlinks.isAdminFromRoles(user, collection, proof); ``` ## Cache Utilities The `cache` module provides TTL-based caching: ```typescript import * as smartlinks from '@proveanything/smartlinks'; // Cache with 5-minute TTL const apps = await smartlinks.cache.getOrFetch( 'apps:acme-wines', () => smartlinks.collection.getApps('acme-wines'), { ttl: 5 * 60 * 1000, storage: 'session' } ); // Invalidate cache smartlinks.cache.invalidate('apps:acme-wines'); // Clear all cache smartlinks.cache.clear(); ``` ### Cache Storage Options - `memory` - In-memory only (default, cleared on page reload) - `session` - sessionStorage (persists across page reloads in same tab) - `local` - localStorage (persists across browser sessions) ## Backend API Requirement The SDK uses the existing `getAppsConfig()` endpoint: ``` GET /public/collection/:collectionId/apps-config Response: { "apps": [ { "id": "warranty-registration", "srcAppId": "warranty-v2", "name": "Warranty Registration", "publicIframeUrl": "https://warranty.lovable.app", "active": true, "category": "Commerce", "usage": { "collection": true, "product": true, "proof": false, "widget": false } } ] } ``` Each app must have a `publicIframeUrl` configured for the IframeResponder to work. ## Complete Integration Example ```typescript import * as smartlinks from '@proveanything/smartlinks'; // Initialize SDK smartlinks.initializeApi({ baseURL: 'https://api.smartlinks.io', apiKey: 'your-api-key', }); class AppManager { private responder: smartlinks.IframeResponder | null = null; async loadApp(containerId: string) { // Create iframe const iframe = document.createElement('iframe'); iframe.style.width = '100%'; iframe.style.border = 'none'; document.getElementById(containerId)?.appendChild(iframe); // Fetch context data const collection = await smartlinks.collection.get('acme-wines'); const product = await smartlinks.product.get('wine-123'); // Determine admin status try { const user = await smartlinks.auth.getAccountInfo(); const isAdmin = smartlinks.isAdminFromRoles(user, collection); // Create responder this.responder = new smartlinks.IframeResponder({ collectionId: 'acme-wines', appId: 'warranty', productId: 'wine-123', isAdmin, cache: { collection, product, user }, onAuthLogin: async (token, user) => { smartlinks.setBearerToken(token); this.responder?.updateCache({ user }); }, onAuthLogout: async () => { smartlinks.setBearerToken(''); this.responder?.updateCache({ user: null }); }, onRouteChange: (path, state) => { console.log('Route changed:', path, state); }, onResize: (height) => { iframe.style.height = `${height}px`; }, onError: (error) => { console.error('Iframe error:', error); }, }); // Attach and load const src = await this.responder.attach(iframe); iframe.src = src; } catch (err) { console.error('Failed to load app:', err); } } destroy() { this.responder?.destroy(); this.responder = null; } } // Usage const manager = new AppManager(); await manager.loadApp('app-container'); // Cleanup on unmount // manager.destroy(); ``` ## Message Protocol The responder handles these message types from the iframe: ### Route Changes ```typescript { type: 'smartlinks-route-change', path: '/products/wine-123', context: { collectionId: 'acme-wines', appId: 'warranty' }, state: { tab: 'details' } } ``` ### Resize Events ```typescript { _smartlinksIframeMessage: true, type: 'smartlinks:resize', payload: { height: 800 } } ``` ### Authentication ```typescript { _smartlinksIframeMessage: true, type: 'smartlinks:authkit:login', payload: { token: '...', user: {...}, messageId: 'abc123' } } ``` ### API Proxy ```typescript { _smartlinksProxyRequest: true, id: 'req-123', method: 'GET', path: 'public/product/wine-123', headers: { 'Authorization': 'Bearer ...' } } ``` ## TypeScript Support All types are exported for full TypeScript support: ```typescript import type { IframeResponderOptions, CachedData, CollectionApp, RouteChangeMessage, SmartlinksIframeMessage, } from '@proveanything/smartlinks'; ``` ## Browser Compatibility - Modern browsers with ES6+ support - Requires `postMessage`, `MessageEvent`, `ResizeObserver` APIs - Falls back gracefully in Node.js environments (no-ops for browser-only features) ## Best Practices 1. **Pre-cache data** when possible to eliminate loading delays 2. **Handle onError** to catch and log communication issues 3. **Clean up** responder with `destroy()` when unmounting 4. **Use version control** to test against development versions 5. **Validate tokens** server-side for security 6. **Set height dynamically** based on content using `onResize` 7. **Sync routes** with parent navigation for better UX ## Troubleshooting ### App not loading - Verify the appId exists in collection apps configuration - Check browser console for errors - Ensure backend API endpoint is available ### Authentication not syncing - Implement `onAuthLogin` callback - Verify token is being set with `setBearerToken()` - Check that auth messages are being received ### Height not updating - Implement `onResize` callback - Ensure iframe has CSS styling for height changes - Check that iframe is sending resize messages ### Cache not working - Verify cache storage option is supported (sessionStorage/localStorage) - Check browser storage isn't disabled - Clear cache and retry with `smartlinks.cache.clear()` --- # SDK guide: Container Tracking Source: https://docs.smartlinks.app/docs/sdk/guides/container-tracking Physical or logical groupings with hierarchical nesting, item membership, and attestation history. # Container Tracking > Physical or logical groupings with hierarchical nesting, item membership, and attestation history. --- ## Table of Contents - [Overview](#overview) - [TypeScript Interfaces](#typescript-interfaces) - [SDK Usage](#sdk-usage) - [Admin — Containers](#admin--containers) - [Admin — Item Membership](#admin--item-membership) - [Public — Read-Only](#public--read-only) - [REST API Reference](#rest-api-reference) - [Attestations on Containers](#attestations-on-containers) - [Cold-Chain Tracking Example](#cold-chain-tracking-example) - [Design Notes](#design-notes) --- ## Overview A **container** is any physical or logical grouping that can hold items and accumulate a history. Examples: | `containerType` | What it represents | |---|---| | `'pallet'` | A pallet of goods | | `'fridge'` | A refrigerated unit | | `'cask'` | A whiskey cask | | `'shipping_container'` | A maritime shipping container | | `'warehouse'` | A storage facility | | `'ship'` | A vessel carrying other containers | Containers support: - **Hierarchical nesting** — a ship can contain shipping containers, which contain pallets, which contain individual items. - **Item membership** — any `tag`, `proof`, `serial`, `order_item`, or nested `container` can be placed in and removed from a container. Membership is tracked with timestamps so the full history is available. - **Attestations** — measurements and facts (temperature readings, ABV checks, condition reports, etc.) can be appended to any container using the `attestations` namespace. - **Soft-delete** — deleting a container only sets `deletedAt`; the record and full item history are preserved. --- ## TypeScript Interfaces ```typescript type ContainerStatus = 'active' | 'archived' | string type ContainerItemType = 'tag' | 'proof' | 'serial' | 'order_item' | 'container' interface Container { id: string orgId: string collectionId: string containerType: string // 'pallet' | 'fridge' | 'cask' | … ref?: string // Human-readable identifier / barcode name?: string description?: string status: ContainerStatus // default 'active' metadata?: Record parentContainerId?: string // null = top-level children?: Container[] // Populated when ?tree=true items?: ContainerItem[] // Populated when ?includeContents=true createdAt: string updatedAt: string deletedAt?: string } interface ContainerItem { id: string orgId: string containerId: string collectionId?: string itemType: ContainerItemType itemId: string productId?: string proofId?: string addedAt: string removedAt?: string // null = currently inside; present in history view metadata?: Record } interface CreateContainerInput { containerType: string // required ref?: string name?: string description?: string status?: ContainerStatus metadata?: Record parentContainerId?: string } interface UpdateContainerInput { containerType?: string ref?: string name?: string description?: string status?: ContainerStatus metadata?: Record parentContainerId?: string | null // null promotes to top-level } interface AddContainerItemsInput { items: Array<{ itemType: ContainerItemType // required itemId: string // required productId?: string proofId?: string metadata?: Record }> } interface RemoveContainerItemsInput { ids: string[] // ContainerItem UUIDs to soft-remove } ``` --- ## SDK Usage Import via the top-level SDK export: ```typescript import { containers } from '@proveanything/smartlinks' ``` ### Admin — Containers #### Create a container ```typescript const cask = await containers.create('coll_123', { containerType: 'cask', ref: 'CASK-0042', name: 'Cask 42 — Single Malt', metadata: { distilleryYear: 2019, capacityLitres: 200 }, }) ``` #### List containers ```typescript // All active pallets const { containers: pallets } = await containers.list('coll_123', { containerType: 'pallet', status: 'active', limit: 50, }) // Top-level containers only (no parent) const { containers: roots } = await containers.list('coll_123', { topLevel: true }) ``` #### Get a container — flat, tree, or with contents ```typescript // Flat (default) const cask = await containers.get('coll_123', 'cask-uuid') // Full hierarchy tree (3 levels deep) with current contents const tree = await containers.get('coll_123', 'warehouse-uuid', { tree: true, treeDepth: 3, includeContents: true, }) ``` #### Find containers currently holding an item ```typescript const { containers: holding } = await containers.findForItem('coll_123', { itemType: 'proof', itemId: 'proof-uuid', }) ``` #### Update a container ```typescript const updated = await containers.update('coll_123', 'cask-uuid', { status: 'archived', metadata: { bottledAt: '2025-04-01' }, }) // Promote to top-level (remove parent) await containers.update('coll_123', 'cask-uuid', { parentContainerId: null }) ``` #### Delete (soft) a container ```typescript await containers.remove('coll_123', 'cask-uuid') // deletedAt is set; record remains queryable by admins ``` ### Admin — Item Membership #### List current contents ```typescript const { items } = await containers.listItems('coll_123', 'pallet-uuid') ``` #### List full membership history (including removed items) ```typescript const { items: history } = await containers.listItems('coll_123', 'pallet-uuid', { history: true, }) // history includes items with removedAt !== null ``` #### Add items to a container ```typescript const { items } = await containers.addItems('coll_123', 'pallet-uuid', { items: [ { itemType: 'tag', itemId: 'NFC-00AABBCC' }, { itemType: 'proof', itemId: 'proof-uuid', productId: 'product-id' }, { itemType: 'container', itemId: 'inner-crate-uuid' }, ], }) ``` #### Remove items from a container (soft) ```typescript const result = await containers.removeItems('coll_123', 'pallet-uuid', { ids: ['container-item-uuid-1', 'container-item-uuid-2'], }) console.log(`Removed ${result.removedCount} items`) ``` ### Public — Read-Only ```typescript // List (excludes soft-deleted and non-public containers) const { containers: list } = await containers.publicList('coll_123', { containerType: 'cask', }) // Get with tree const tree = await containers.publicGet('coll_123', 'warehouse-uuid', { tree: true, }) // Current contents only (no history on public side) const { items } = await containers.publicListItems('coll_123', 'cask-uuid') ``` --- ## REST API Reference ### Admin endpoints ``` POST /api/v1/admin/collection/:collectionId/containers GET /api/v1/admin/collection/:collectionId/containers GET /api/v1/admin/collection/:collectionId/containers/find-for-item GET /api/v1/admin/collection/:collectionId/containers/:containerId PATCH /api/v1/admin/collection/:collectionId/containers/:containerId DELETE /api/v1/admin/collection/:collectionId/containers/:containerId GET /api/v1/admin/collection/:collectionId/containers/:containerId/items POST /api/v1/admin/collection/:collectionId/containers/:containerId/items DELETE /api/v1/admin/collection/:collectionId/containers/:containerId/items ``` #### GET /containers query parameters | Parameter | Description | |---|---| | `containerType` | Filter by type string | | `status` | Filter by status | | `ref` | Filter by reference | | `parentContainerId` | Filter by parent UUID | | `topLevel` | `true` to return only root containers | | `limit` | Default `100` | | `offset` | Default `0` | #### GET /containers/:id query parameters | Parameter | Description | |---|---| | `tree` | `true` to recursively embed child containers | | `treeDepth` | Max nesting depth (default: unlimited) | | `includeContents` | `true` to embed current items | #### GET /containers/:id/items query parameters | Parameter | Description | |---|---| | `history` | `true` to include removed items | | `limit` | Default `100` | | `offset` | Default `0` | #### POST /containers/:id/items body ```json { "items": [ { "itemType": "tag", "itemId": "NFC-00AABBCC" }, { "itemType": "proof", "itemId": "proof-uuid", "productId": "product-id" } ] } ``` #### DELETE /containers/:id/items body ```json { "ids": ["container-item-uuid-1", "container-item-uuid-2"] } ``` ### Public endpoints ``` GET /api/v1/public/collection/:collectionId/containers GET /api/v1/public/collection/:collectionId/containers/:containerId GET /api/v1/public/collection/:collectionId/containers/:containerId/items ``` Same query parameters as admin (minus `history`). Soft-deleted containers and containers with `metadata.publicListing === false` are excluded from list results. --- ## Attestations on Containers Attestations (sensor readings, condition reports, etc.) are managed via the `attestations` namespace. The admin router does **not** expose attestation sub-routes — use the standalone `/admin/.../attestations` router for all admin attestation access. ```typescript import { attestations } from '@proveanything/smartlinks' // Record a temperature reading against a cask await attestations.create('coll_123', { subjectType: 'container', subjectId: 'cask-uuid', attestationType: 'temperature', recordedAt: new Date().toISOString(), value: { celsius: 12.4 }, unit: '°C', }) // Latest readings on all attestation types const { latest } = await attestations.latest('coll_123', { subjectType: 'container', subjectId: 'cask-uuid', }) ``` Public shortcuts pre-scoped to a container are also available: ```typescript const { latest } = await attestations.publicContainerLatest('coll_123', 'cask-uuid') const { summary } = await attestations.publicContainerSummary('coll_123', 'cask-uuid', { attestationType: 'temperature', valueField: 'celsius', groupBy: 'hour', }) ``` --- ## Cold-Chain Tracking Example **Goal:** determine whether a refrigerated item was inside a fridge when the temperature exceeded a threshold. ```typescript import { containers, attestations } from '@proveanything/smartlinks' const collectionId = 'coll_123' const fridgeId = 'fridge-uuid' const proofId = 'bottle-proof-uuid' const threshold = 8 // °C // 1. Get the full item membership history for the fridge const { items: history } = await containers.listItems(collectionId, fridgeId, { history: true, }) // 2. Find the window(s) when our bottle was inside the fridge const bottleIntervals = history .filter(item => item.itemType === 'proof' && item.itemId === proofId) .map(item => ({ addedAt: new Date(item.addedAt), removedAt: item.removedAt ? new Date(item.removedAt) : new Date(), })) // 3. Get temperature attestations for the fridge const { attestations: temps } = await attestations.list(collectionId, { subjectType: 'container', subjectId: fridgeId, attestationType: 'temperature', limit: 1000, }) // 4. Cross-reference const excursions = temps.filter(a => { const celsius = a.value?.celsius as number const recorded = new Date(a.recordedAt) if (celsius <= threshold) return false return bottleIntervals.some( ({ addedAt, removedAt }) => recorded >= addedAt && recorded <= removedAt ) }) if (excursions.length > 0) { console.warn( `⚠️ ${excursions.length} temperature excursion(s) recorded while the bottle was in the fridge.` ) } else { console.log('✅ No temperature excursions during the bottle\'s time in the fridge.') } ``` --- ## Design Notes ### Soft-delete `DELETE /containers/:id` only sets `deletedAt`. The container and its full item history remain queryable by admins. The public API automatically excludes deleted containers from all responses. ### Membership history `ContainerItem` records are never deleted. When an item is removed, `removedAt` is set. This means you always have a full audit trail of what was in a container and when, which is essential for provenance and compliance use-cases. ### Public visibility Containers with `metadata.publicListing === false` are excluded from public list endpoints but remain accessible by direct ID if the caller knows the UUID. Use this to hide containers from general browsing while still allowing deep-link access. ### Nesting Containers can be nested arbitrarily. A container with `containerType='container'` in the `items` list is a child container. The `findForItem` endpoint performs a flat lookup across all containers — it does not traverse the hierarchy. ### Attestation ownership for containers Owner elevation on attestation public endpoints resolves ownership via `container.metadata.proofId`. If you want individual users to receive owner-tier attestation data for a container, set `metadata.proofId` to a proof they own. --- # SDK guide: App Records Pattern Source: https://docs.smartlinks.app/docs/sdk/guides/app-records-pattern Canonical guide for microapps storing per-product, per-variant, per-batch, or rule-targeted data. # SmartLinks App Records Pattern > Canonical guide for microapps that store **per-product**, **per-facet**, **per-variant**, **per-batch**, or **rule-targeted** data. > > Audience: microapp developers (ingredients, nutrition, allergy, FAQs, recipes, warranty, provenance, …). > > Status: **standard**. New apps MUST follow this contract; existing apps SHOULD migrate. > > SDK: `@proveanything/smartlinks` ≥ **1.11**. > Admin shell (React only): `@proveanything/smartlinks-utils-ui` ≥ **0.7.6** — required for the admin side if using the React shell; not needed in public widgets. --- ## 0. TL;DR — pick your shape, then copy the snippet Every records-based app fits into a 2×2: | | **Singleton** (one record per scope) | **Collection** (many records per scope) | | ------------------------ | ---------------------------------------------------- | ------------------------------------------------------------- | | **Best-match (one wins)**| Ingredients, nutrition, washing instructions | _(rare — usually you want all)_ | | **All matches (aggregate)** | _(rare — usually you want best)_ | FAQs, recipes, SOPs, care tips, story cards | That choice drives **three** things and nothing else: 1. **Manifest:** `cardinality: 'singleton' | 'collection'` and `allowFacetRules: boolean`. 2. **Admin:** use `` (React) or call the admin SDK functions directly. Pass `cardinality` + include `'rule'` in `scopes` if `allowFacetRules`. 3. **Public widget:** call `app.records.match()` (best match / singleton) or `app.records.resolveAll()` (all matches / collection). These are plain SDK calls with no framework dependency. If you only remember one rule: **never write your own resolution loop**. The server already walks the chain correctly — calling `match()` or `resolveAll()` is the entire public-side implementation. --- ## 1. The data model in one paragraph A microapp owns a typed **records table** keyed by `(appId, recordType, id)`. Each `AppRecord` carries a `data` payload plus **either** a structured `scope` (anchored to a node in the chain) **or** a `facetRule` (matches products dynamically by their facets). Records also carry a `status` (`'active'` | `'draft'` | `'archived'`) and optional `startsAt` / `expiresAt` timestamps. The server resolves which record(s) apply to a given product context. There is no "global"; the top of the chain is **collection** — anything not explicitly scoped further applies to the whole collection. ```ts import * as SL from '@proveanything/smartlinks'; await SL.app.records.upsert(collectionId, appId, { recordType: 'ingredients', scope: { productId: 'prod_abc', variantId: 'var_500ml' }, // server derives the ref data: { /* domain payload */ }, }, /* admin */ true); ``` Or, for a rule-targeted record: ```ts await SL.app.records.upsert(collectionId, appId, { recordType: 'ingredients', facetRule: { all: [ { facetKey: 'brand', anyOf: ['acme'] }, { facetKey: 'category', anyOf: ['bread', 'pastry'] }, ], }, data: { /* domain payload */ }, }, true); ``` `scope` and `facetRule` are **mutually exclusive on save**. --- ## 2. Resolution order (one canonical chain) The server walks **most-specific → least-specific** and stops at the first match (for best-match) or collects every match (for aggregate): ``` proof → batch → variant → product → rule(*) → facet(*) → collection ``` - `rule(*)` — facet-rule records are scored by **specificity** (number of clauses + number of constrained values). The most specific rule wins. - `facet(*)` — legacy single-facet anchors, walked deterministically (alphabetical). - `collection` — the top of the chain. **There is no "global" tier above collection.** A collection-level record is the catch-all for that collection. The resolved value comes back tagged with `matchedAt: 'product' | 'rule' | 'facet' | …` so the UI can say things like _"Matched by rule: brand=Acme AND category=bread"_. > ⚠️ Legacy `scope.facets[]` (colon-delimited single-facet refs) is deprecated and removed in SDK 1.12. Use `facetRule` for everything that isn't a one-off facet pin. --- ## 3. Manifest declaration Declare each record type once in `app.admin.json`. The shell and the platform read this to render the right scope tabs and disable the wrong affordances. ```json { "records": { "ingredients": { "label": "Ingredients", "cardinality": "singleton", "allowFacetRules": true, "scopes": ["collection", "facet", "rule", "product", "variant", "batch"], "defaultScope": "product" }, "faq": { "label": "FAQs", "cardinality": "collection", "allowFacetRules": true, "scopes": ["collection", "rule", "product"], "defaultScope": "collection" } } } ``` | Field | Meaning | | ----------------- | ------------------------------------------------------------------------------------------------------------------------------------ | | `cardinality` | `'singleton'` (one per scope, e.g. ingredients) or `'collection'` (many per scope, e.g. FAQs). Default `'singleton'`. | | `allowFacetRules` | `true` to enable the `rule` scope tab + `` in the shell. Default `false`. | | `scopes` | Allowed scope kinds in **resolution order**. `'rule'` is a synthetic scope that holds rule-targeted records. | | `defaultScope` | Where the "Create new" button lands. | | `label` | Human-readable label used in headings and toasts. | --- ## 4. Admin side > The admin shell and rule editor are part of `@proveanything/smartlinks-utils-ui`, which is a **React-only library**. It is only needed in admin dashboards — never import it in a public widget. ### `` (React) The shell owns: scope tabs, browser pane, rule editor, save/discard, dirty navigation, inheritance markers, deletion, CSV, bulk apply, deep linking. **You only supply the editor for one record's `data`.** ```tsx import * as SL from '@proveanything/smartlinks'; import { RecordsAdminShell } from '@proveanything/smartlinks-utils-ui/records-admin'; SL={SL} collectionId={collectionId} appId={appId} recordType="ingredients" label="Ingredients" cardinality="singleton" // ← from manifest scopes={['collection', 'facet', 'rule', 'product', 'variant', 'batch']} defaultScope="product" defaultData={() => emptyConfig()} renderEditor={(ctx) => ( )} /> ``` ### What the shell gives you for free - **Scope tabs** including a **`Rule`** tab when `'rule'` is in `scopes`. Selecting it opens `` above your editor — no extra wiring. - **`EditorContext.facetRule` / `onFacetRuleChange`** for rule-scoped records, plus `canSave: false` until at least one clause has values (avoids server 500s). - **Inheritance markers** — when editing a variant, the product baseline is shown; per-field "↩ Inherited" / "● Override" is rendered by the inheritance helpers. - **Collection cardinality flow** — set `cardinality="collection"` and the shell turns the right pane into a list of items (table / cards / gallery) with `+ New` and per-item nav. - **Telemetry** — `record.save`, `record.delete`, `scope.change`, `csv.import`, `bulk.apply`, `item.create`, etc. via `onTelemetry`. ### Standalone rule editor If you need a rule editor outside the shell (e.g. on a settings page): ```tsx import { FacetRuleEditor } from '@proveanything/smartlinks-utils-ui/facet-rule-editor'; ``` --- ## 5. Public side > **Do not import `@proveanything/smartlinks-utils-ui` in a public widget.** It is a React admin library. Public widgets only need `@proveanything/smartlinks`. The SDK is framework-agnostic. Public widgets call two endpoints depending on cardinality: | Cardinality | Call | What it does | |---|---|---| | **Singleton** (one answer) | `app.records.match()` | Server walks the chain, returns the best-matching record | | **Collection** (all answers) | `app.records.resolveAll()` | Server walks the chain, returns every matching record | Neither call requires React or any other framework — wrap them in whatever async pattern your widget uses. > **Admin vs public — the rule is simple:** > > | Function | Public widget | Admin dashboard | > |---|---|---| > | `app.records.create(…, false)` | ✅ default — omit the flag | ✅ pass `true` | > | `app.records.list(…, false)` | ✅ default — omit the flag | ✅ pass `true` | > | `app.records.get(…, false)` | ✅ default — omit the flag | ✅ pass `true` | > | `app.records.update(…, false)` | ✅ default — omit the flag | ✅ pass `true` | > | `app.records.remove(…, false)` | ✅ default — omit the flag | ✅ pass `true` | > | `app.records.aggregate(…, false)` | ✅ default — omit the flag | ✅ pass `true` | > | `app.records.match(…, false)` | ✅ default — omit the flag | ✅ pass `true` | > | `app.records.resolveAll(…, false)` | ✅ default — omit the flag | ✅ pass `true` | > | `app.records.upsert()` | ❌ admin only — no public path | ✅ | > | `app.records.bulkUpsert()` | ❌ admin only — no public path | ✅ | > | `app.records.bulkDelete()` | ❌ admin only — no public path | ✅ | > | `app.records.restore()` | ❌ admin only — no public path | ✅ | > | `app.records.previewRule()` | ❌ admin only — no public path | ✅ | ### 5a. Singleton — `app.records.match()` (best match wins) Use when the widget shows **one** answer for the current product (ingredients, nutrition, warranty terms, washing instructions). ```ts import * as SL from '@proveanything/smartlinks'; const result = await SL.app.records.match(collectionId, appId, { target: { productId, variantId, batchId }, // pass whatever context you have strategy: 'best', recordType: 'ingredients', }); // result.data[0] → the single highest-specificity MatchEntry (when strategy: 'best') // result.data[0].matchedAt → 'product' | 'rule' | 'facet' | 'collection' | … // result.data[0].data → your record payload ``` The server walks `proof → batch → variant → product → rule → facet → collection` and returns the first match. `result.data` will have at most one entry when `strategy: 'best'`. ### 5b. Collection — `app.records.resolveAll()` (every match, ordered) Use when the widget shows **many** answers across the chain (FAQs, recipes, care tips, SOPs). ```ts const result = await SL.app.records.resolveAll(collectionId, appId, { context: { productId }, // note: resolveAll uses 'context', not 'target' recordType: 'faq', // singular — omit to return all record types }); // result.records → ResolveAllEntry[] sorted most-specific first // each entry: { record: AppRecord, matchedAt, specificity, matchedRule? } ``` ### 5c. Multi-type — `app.records.resolveAll()` without a recordType filter When you need records of all types for a context in one call (rare; executors, SEO surfaces), omit `recordType`: ```ts const result = await SL.app.records.resolveAll(collectionId, appId, { context: { productId, facets: { brand: ['acme'] }, // include facets to match rule records }, // no recordType → returns all declared types }); // result.records → one ResolveAllEntry per matched record, all types interleaved // filter client-side by entry.record.recordType if you need to separate them ``` ### 5d. Status filtering and the draft → active lifecycle Every record has a `status` field with three canonical values: | Value | Meaning | Returned to public/owner callers? | |---|---|---| | `active` | Live and current | ✅ Yes | | `draft` | Being prepared, not yet published | ❌ No | | `archived` | Previously live, retained for history | ❌ No | **Enforcement:** `match()`, `resolveAll()`, and `GET /records` (query) now only return `status: "active"` records to public and owner callers. Admin callers receive all statuses as before; use explicit `status` filters (`status=draft`, `status=archived`) to narrow results. **`active` is the default** when no `status` is supplied on creation, so existing records and simple creation flows are unaffected. **Draft → publish workflow:** create the record with `status: 'draft'` so it is invisible to public widgets, then update it to `status: 'active'` when ready to publish. ```ts // Create a record that is not yet publicly visible await SL.app.records.upsert(collectionId, appId, { recordType: 'ingredients', scope: { productId }, data: { /* draft payload */ }, status: 'draft', }, /* admin */ true); // Publish it await SL.app.records.upsert(collectionId, appId, { recordType: 'ingredients', scope: { productId }, data: { /* final payload */ }, status: 'active', }, true); ``` **Composes with `startsAt` / `expiresAt`:** a record must satisfy **both** the status check and the time window to be returned to public callers. A record that is `active` but whose `startsAt` is in the future, or whose `expiresAt` has passed, is excluded. --- ### Common mistakes (do not do these) | ❌ Anti-pattern | ✅ Do this instead | | ---------------------------------------------------------- | ----------------------------------------------------------------- | | Importing anything from `@proveanything/smartlinks-utils-ui` in a public widget | That package is React-only and admin-only. Public widgets only use `@proveanything/smartlinks`. | | Calling `SL.app.records.list()` and filtering client-side | `app.records.match()` (singleton) or `app.records.resolveAll()` (collection). The server walks the chain. | | Calling `SL.app.records.list(…, true)` from a public widget | Omit the `admin` flag — it defaults to `false`. | | Calling `SL.app.records.match(…, true)` from a public widget | Omit the `admin` flag — it defaults to `false`. | | Calling `SL.app.records.resolveAll(…, true)` from a public widget | Omit the `admin` flag — it defaults to `false`. | | Calling `upsert`, `bulkUpsert`, `bulkDelete`, or `previewRule` from widget code | Those are admin-only. Widget code reads data; it never writes records. | | Walking the chain by hand with multiple `get` / `list` calls | One `match()` or `resolveAll()` call. The server handles the resolution order including rules. | | Treating `facet:key:value` refs as the rule mechanism | Use `facetRule` (`{ all: [{ facetKey, anyOf: [...] }] }`). Multi-condition, scored by specificity. | | Reading `matchedAt === 'global'` | There is no `'global'`. The top of the chain is `'collection'`. | | Expecting `draft` or `archived` records to appear in public widget results | Public/owner callers only receive `status: "active"` records from `match()`, `resolveAll()`, and `GET /records`. Use admin calls to query by other statuses. | | Setting `status: 'active'` and wondering why a record is still hidden | Check `startsAt` / `expiresAt` — a record must satisfy both the status check and the time window. | --- ## 6. Reference: the `EditorContext` your `renderEditor` receives ```ts interface EditorContext { value: TData; onChange: (next: TData) => void; source: 'self' | 'inherited' | 'empty'; recordId?: string; parentValue?: TData | null; scope: ParsedRef; // { kind: 'product' | 'rule' | …, productId?, … } // Save lifecycle isDirty: boolean; isSaving?: boolean; saveError?: unknown | null; canSave?: boolean; // shell flips to false on empty rules cannotSaveReason?: string; save: () => Promise; reset: () => void; // Deletion remove: () => Promise; canRemove: boolean; // Rule scope only facetRule?: FacetRule | null; onFacetRuleChange?: (next: FacetRule | null) => void; } ``` --- ## 7. Migration checklist (existing apps) 1. **Update SDKs:** `@proveanything/smartlinks@^1.11`, `@proveanything/smartlinks-utils-ui@^0.7.6`. 2. **Add `cardinality` and `allowFacetRules`** to every entry under `records` in `app.admin.json`. 3. **Add `'rule'` (and `'collection'` if missing) to `scopes`** wherever `allowFacetRules: true`. 4. **Pass `cardinality`** to ``. 5. **Replace any handwritten chain walking** with `app.records.match()` (singleton) or `app.records.resolveAll()` (collection). If you are using React, the `useResolvedRecord` / `useCollectedRecords` hooks from `@proveanything/smartlinks-utils-ui` wrap these calls — but they are **admin-side React helpers**, not for public widgets. 6. **Delete any code that constructs `facet:key:value` refs** for matching. Use `facetRule` via the shell or `` (React admin) or pass `facetRule` directly in `upsert()` calls. 7. **Search for the word "global"** in your code/docs and rename to "collection" — this is the most common source of confusion. 8. **Audit records that should not be public yet:** any record that previously relied on obscurity (e.g. not linked in the widget, no active product) is now filtered by `status`. Set `status: 'draft'` on records that are not ready and `status: 'active'` when publishing. Records without an explicit status were created as `active` and are unaffected. --- ## 8. Where the canonical exports live ### Public widgets (any framework) | Need | Import from | | ---- | ----------- | | Best-match resolution (singleton) | `@proveanything/smartlinks` → `SL.app.records.match()` | | All-matches resolution (collection) | `@proveanything/smartlinks` → `SL.app.records.resolveAll()` | | Record CRUD (public path) | `@proveanything/smartlinks` → `SL.app.records.{list, get, create, update, remove, aggregate}` | ### Admin dashboards (React) > All of the following are from `@proveanything/smartlinks-utils-ui`, a **React-only** package. Do not use in public widgets. | Need | Import from | | ---- | ----------- | | Admin shell | `@proveanything/smartlinks-utils-ui/records-admin` → `RecordsAdminShell` | | Standalone rule editor | `@proveanything/smartlinks-utils-ui/facet-rule-editor` → `FacetRuleEditor` | | Conditions editor (non-facet) | `@proveanything/smartlinks-utils-ui/conditions-editor` → `ConditionsEditor` | | Best-match hook (React convenience wrapper) | `@proveanything/smartlinks-utils-ui/records-admin` → `useResolvedRecord` | | All-matches hook (React convenience wrapper) | `@proveanything/smartlinks-utils-ui/records-admin` → `useCollectedRecords` | | Multi-type hook (React convenience wrapper) | `@proveanything/smartlinks-utils-ui/records-admin` → `useResolveAllRecords` | | Rule preview hook | `@proveanything/smartlinks-utils-ui/records-admin` → `useRulePreview` | | Admin record CRUD (admin path) | `@proveanything/smartlinks` → `SL.app.records.{upsert, bulkUpsert, bulkDelete, restore, previewRule}` | --- _End of doc. If anything below the SDK contradicts this file, this file wins — open a PR against the SDK to bring the two back into sync._ --- # SDK guide: Communications & Broadcasts Source: https://docs.smartlinks.app/docs/sdk/guides/comms Transactional sends, multi-channel broadcasts, consent management, push registration, and analytics. # Communications (Comms & Broadcasts) This guide covers the full communications surface of the SDK: transactional sends, multi-channel broadcasts, consent management, push registration, and analytics. --- ## What You Can Do - **Transactional Send** — Fire a single targeted message to one contact without creating a broadcast record. - **Broadcasts** — Define and enqueue multi-channel campaigns (email, push, SMS, wallet); preview and test before sending. - **Comms Settings** — Configure topics and unsubscribe behaviour per collection. - **Consent & Preferences** — Record opt-ins/outs and per-subject preferences. - **Method Registration** — Register email, SMS, and Web Push contact methods. - **Analytics** — Query delivery events, recipient lists, and log custom events. --- ## Transactional Send Sends a single message to one contact using a template. No broadcast record is created. The send is logged to the contact's communication history with `sourceType: 'transactional'`. **Endpoint:** `POST /admin/collection/:collectionId/comm.send` ```typescript import { comms } from '@proveanything/smartlinks' import type { TransactionalSendRequest } from '@proveanything/smartlinks' const result = await comms.sendTransactional('collectionId', { contactId: 'e4f2a1b0-...', templateId: 'warranty-update', channel: 'preferred', // 'email' | 'sms' | 'push' | 'wallet' | 'preferred' (default) props: { claimRef: 'CLM-0042', decision: 'approved' }, include: { productId: 'prod-abc123', // hydrate product context into template appCase: 'c9d1e2f3-...', // hydrate an app case record }, ref: 'warranty-decision-notification', // arbitrary string for your own tracking appId: 'warrantyApp', }) if (result.ok) { console.log(`Sent via ${result.channel}`, result.messageId) } else { console.error('Send failed:', result.error) } ``` ### Request Body | Field | Type | Description | |-------|------|-------------| | `contactId` | `string` | **Required.** Contact to send to. | | `templateId` | `string` | **Required.** Template to render. | | `channel` | `'email' \| 'sms' \| 'push' \| 'wallet' \| 'preferred'` | Channel to send on. `'preferred'` (default) picks the contact's best available channel. | | `props` | `Record` | Extra variables merged into template rendering. | | `include` | object | Hydration flags — see table below. | | `ref` | `string` | Arbitrary reference string stored with the event. | | `appId` | `string` | App identifier, used for context/logging. | **`include` fields:** | Field | Type | Description | |-------|------|-------------| | `collection` | `boolean` | Include collection data in template context. | | `productId` | `string` | Hydrate a specific product. | | `proofId` | `string` | Hydrate a specific proof. | | `user` | `boolean` | Include the requesting user's data. | | `appCase` | `string` | Hydrate an app case record by ID. | | `appThread` | `string` | Hydrate an app thread record by ID. | | `appRecord` | `string` | Hydrate an app record by ID. | ### Response ```typescript // Success { ok: true; channel: 'email' | 'sms' | 'push' | 'wallet'; messageId?: string } // Failure { ok: false; error: string } ``` --- ## Broadcasts Broadcasts are multi-channel campaigns defined and managed under a collection. Create a broadcast record in the platform first, then use the SDK to preview, test, and enqueue. **Import:** ```typescript import { broadcasts } from '@proveanything/smartlinks' import type { BroadcastSendRequest } from '@proveanything/smartlinks' ``` ### Preview Render a broadcast template for a given channel without sending. ```typescript const preview = await broadcasts.preview('collectionId', 'broadcastId', { email: 'user@example.com', // optional: override recipient email for preview channelOverride: 'push', // force a specific channel props: { productId: 'prod_123' }, hydrate: true, include: { product: true, proof: true } }) // preview.channel — which channel was rendered // preview.payload / preview.subject / preview.body — channel-specific render output ``` ### Test Send Send a single test message to a contact. ```typescript await broadcasts.sendTest('collectionId', 'broadcastId', { contactId: 'contact_123', props: { promo: 'JAN' } }) ``` ### Enqueue Production Send Enqueue a background send to all recipients. When `channel` is omitted the broadcast's own mode setting drives channel selection: - `preferred` — picks the best single channel per recipient. - `channels` / `all` — sends on every enabled channel. ```typescript const body: BroadcastSendRequest = { pageSize: 200, hydrate: true, include: { product: true, proof: true, user: true } } await broadcasts.send('collectionId', 'broadcastId', body) ``` ### Manual Send ```typescript await broadcasts.sendManual('collectionId', 'broadcastId', { /* overrides */ }) ``` ### Topic Targeting and Consent A broadcast must declare a `topic` under `broadcast.data.topic` (e.g. `newsletter`, `critical`). The topic key must match one defined in your collection's comms settings, and consent enforcement uses it at send time. Consent resolution order (per recipient, per channel): 1. `preferences._default.topicsByChannel[channel][topic]` 2. `preferences._default.channels[channel]` 3. `preferences._default.topics[topic]` 4. Subject-specific preferences (when hydrating a subject context) Minimal broadcast data shape: ```json { "data": { "topic": "newsletter", "channelSettings": { "mode": "preferred", "channels": [ { "channel": "email", "enabled": true, "priority": 1, "templateId": "tmpl_newsletter_email" }, { "channel": "push", "enabled": true, "priority": 2 } ] } } } ``` ### Append Recipients and Events ```typescript // Append a single delivery event await broadcasts.append('collectionId', { broadcastId: 'broadcastId', contactId: 'contact_123', channel: 'email', eventType: 'delivered', outcome: 'success' }) // Append bulk events await broadcasts.appendBulk('collectionId', { params: { broadcastId: 'broadcastId', channel: 'push' }, ids: ['contact_1', 'contact_2'], idField: 'contactId' }) // List recipients const recips = await broadcasts.recipients('collectionId', 'broadcastId', { limit: 50 }) ``` --- ## Admin Comms Settings Configure unsubscribe behaviour and topics per collection. **Base path:** `admin/collection/:collectionId/comm.settings` ### Get Settings ```typescript import { comms } from '@proveanything/smartlinks' const { settings } = await comms.getSettings('collectionId', { includeSecret: true }) // settings.unsub — { requireToken, hasSecret } // settings.topics — map of topic key → config ``` ### Patch Settings ```typescript await comms.patchSettings('collectionId', { unsub: { requireToken: true, secret: '' }, topics: { /* ... */ } }) ``` To clear the unsubscribe secret, send `secret: ''`. ### Topics Config Each topic key maps to a config object: ```json { "newsletter": { "label": "Newsletter", "description": "Occasional updates and stories", "classification": "marketing", "defaults": { "policy": "opt-in", "byChannel": { "email": "opt-in", "push": "opt-in" }, "channels": { "email": true, "push": true, "sms": false } }, "rules": { "allowChannels": ["email", "sms", "push"], "allowUnsubscribe": true }, "required": false }, "critical": { "label": "Critical Notices", "classification": "transactional", "defaults": { "policy": "opt-out", "channels": { "email": true, "push": true } }, "rules": { "allowChannels": ["email", "push"], "allowUnsubscribe": false }, "required": true } } ``` - `classification`: `'transactional'` or `'marketing'` — used by the UI when no explicit policy is set. - `defaults.policy`: `'opt-in'` or `'opt-out'` — fallback policy for channels not overridden by `byChannel`. - `required: true` and `rules.allowUnsubscribe: false` — use for critical/mandatory messages. ### Unsubscribe Tokens If `unsub.requireToken` is true, public unsubscribe calls must include a `token` query param. Token generation (topic opt-out): ``` basis = "${contactId}:${topic}" token = sha256(basis + ":" + unsub.secret) // hex ``` Token generation (channel opt-out): ``` basis = "${contactId}:channel:${channel}" token = sha256(basis + ":" + unsub.secret) ``` Browser helper: ```typescript async function sha256Hex(input: string): Promise { const data = new TextEncoder().encode(input) const hash = await crypto.subtle.digest('SHA-256', data) return Array.from(new Uint8Array(hash)) .map(b => b.toString(16).padStart(2, '0')) .join('') } const token = await sha256Hex(`${contactId}:${topic}:${secret}`) ``` --- ## Public Comms API All public endpoints live under `public/collection/:collectionId/comm/*` and do not require an API key. ### Get Topics ```typescript const { topics } = await comms.getPublicTopics('collectionId') ``` Each topic carries UI metadata: - `classification`: `'transactional'` | `'marketing'` - `defaults.policy`: `'opt-in'` | `'opt-out'` - `defaults.byChannel[channel]`: per-channel override Helper to resolve effective policy for a toggle UI: ```typescript type Policy = 'opt-in' | 'opt-out' function effectivePolicy(topic: any, channel: 'email' | 'sms' | 'push' | 'wallet'): Policy { return ( topic?.defaults?.byChannel?.[channel] ?? topic?.defaults?.policy ?? (topic?.classification === 'marketing' ? 'opt-in' : 'opt-out') ) as Policy } ``` ### Consent (Default) Record a contact's default channel and topic opt-ins. ```typescript await comms.upsertConsent('collectionId', { contactId: 'contact_123', channels: { email: true, push: true }, topics: { newsletter: true, critical: true }, topicsByChannel: { email: { newsletter: true }, push: { critical: true } } }) ``` ### Preferences (Subject-Specific) Record consent specific to a product, proof, or other subject. Omit `subject` to update defaults. ```typescript await comms.upsertPreferences('collectionId', { contactId: 'contact_123', subject: { type: 'product', id: 'prod_1' }, channels: { email: true }, topics: { updates: true } }) ``` ### Subscribe / Unsubscribe (Subject) Manage subscriptions to a specific subject (e.g. product updates, proof alerts). ```typescript // Subscribe const { subscriptionId } = await comms.subscribe('collectionId', { contactId: 'contact_123', subject: { type: 'proof', id: 'prf_1', productId: 'prod_1' }, subscribe: true, source: 'api' }) // Unsubscribe (same call, subscribe: false) await comms.subscribe('collectionId', { contactId: 'contact_123', subject: { type: 'proof', id: 'prf_1', productId: 'prod_1' }, subscribe: false, source: 'api' }) ``` ### Check Subscription ```typescript const r = await comms.checkSubscription('collectionId', { contactId: 'contact_123', subjectType: 'proof', subjectId: 'prf_1', productId: 'prod_1' }) // r.subscribed === true | false ``` ### Resolve Subscriptions Find contacts that are subscribed to a subject using identity hints. ```typescript const res = await comms.resolveSubscriptions('collectionId', { subject: { type: 'product', id: 'prod_1' }, hints: { userId: 'user_1', email: 'user@example.com' } }) ``` ### List and Register Methods ```typescript // List registered methods for a contact const { methods } = await comms.listMethods('collectionId', { contactId: 'contact_123', type: 'email' // optional filter: 'email' | 'sms' | 'push' }) // Register email await comms.registerEmail('collectionId', { contactId: 'contact_123', email: 'user@example.com' }) // Register SMS await comms.registerSms('collectionId', { contactId: 'contact_123', phone: '+12065550100' }) ``` ### Unsubscribe (Public Link / One-Click) ```typescript await comms.unsubscribe('collectionId', { contactId: 'contact_123', topic: 'newsletter', token: '' }) ``` Omit `topic` to unsubscribe from all. Omit `channel` to apply across all channels. --- ## Push Registration Web Push requires a service worker and the browser's Push API. ### 1. Fetch the VAPID Public Key ```typescript import { comms } from '@proveanything/smartlinks' const { publicKey } = await comms.getPushVapidPublicKey('collectionId') ``` ### 2. Subscribe in the Service Worker ```typescript const registration = await navigator.serviceWorker.ready const subscription = await registration.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: urlBase64ToUint8Array(publicKey), }) const subJson = subscription.toJSON() as any ``` ### 3. Register the Method ```typescript await comms.registerPush('collectionId', { contactId: 'contact_123', endpoint: subscription.endpoint, keys: subJson?.keys, meta: { userId: 'user_1' } }) ``` ### VAPID Key Utility ```typescript function urlBase64ToUint8Array(base64String: string): Uint8Array { const padding = '='.repeat((4 - (base64String.length % 4)) % 4) const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/') const rawData = atob(base64) const output = new Uint8Array(rawData.length) for (let i = 0; i < rawData.length; i++) output[i] = rawData.charCodeAt(i) return output } ``` --- ## Analytics ### Query Events by User / Contact ```typescript const events = await comms.queryByUser('collectionId', { userId: 'user_123', limit: 100 }) ``` ### Recipient IDs for a Source ```typescript const ids = await comms.queryRecipientIds('collectionId', { broadcastId: 'br_456' }) ``` ### Recipients Without an Action ```typescript const withoutOpen = await comms.queryRecipientsWithoutAction('collectionId', { broadcastId: 'br_456', actionId: 'open' }) ``` ### Recipients With an Action ```typescript const withClick = await comms.queryRecipientsWithAction('collectionId', { broadcastId: 'br_456', actionId: 'click', includeOutcome: true // returns RecipientWithOutcome[] instead of RecipientId[] }) ``` ### Log Events ```typescript // Single event await comms.logCommunicationEvent('collectionId', { eventType: 'opened', channel: 'email', contactId: 'contact_123' }) // Bulk events (same params applied to all IDs) await comms.logBulkCommunicationEvents('collectionId', { params: { broadcastId: 'br_456', channel: 'email', eventType: 'delivered' }, ids: ['contact_1', 'contact_2', 'contact_3'], idField: 'contactId' }) ``` --- ## End-to-End Admin Workflow A typical sequence from setup to delivery: ```typescript import { comms, broadcasts } from '@proveanything/smartlinks' // 1. Configure topics and unsubscribe rules await comms.patchSettings('collectionId', { unsub: { requireToken: true, secret: process.env.UNSUB_SECRET }, topics: { newsletter: { label: 'Newsletter', classification: 'marketing', defaults: { policy: 'opt-in', channels: { email: true, push: true } }, rules: { allowChannels: ['email', 'push', 'sms'], allowUnsubscribe: true } } } }) // 2. Register contact methods (done at contact creation / profile update) await comms.registerEmail('collectionId', { contactId: 'c_123', email: 'user@example.com' }) // 3. Record consent (done when user confirms opt-in) await comms.upsertConsent('collectionId', { contactId: 'c_123', channels: { email: true }, topics: { newsletter: true } }) // 4a. Transactional: send a one-off triggered message const result = await comms.sendTransactional('collectionId', { contactId: 'c_123', templateId: 'order-confirmed', channel: 'email', props: { orderId: 'ORD-001' } }) // 4b. Broadcast: preview → test → enqueue const preview = await broadcasts.preview('collectionId', 'br_spring', { channelOverride: 'email', hydrate: true, include: { product: true } }) await broadcasts.sendTest('collectionId', 'br_spring', { contactId: 'c_123' }) await broadcasts.send('collectionId', 'br_spring', { pageSize: 200, hydrate: true }) ``` --- ## Prerequisites | Feature | Requirement | |---------|-------------| | Email | SendGrid credentials (env or collection comms settings) | | Push | `VAPID_PUBLIC_KEY`, `VAPID_PRIVATE_KEY` env vars | | SMS | Twilio credentials (env or collection comms settings) | | Wallet | Google Wallet issuer + service account | | Hydration | Set `hydrate: true` and populate `include` on broadcast/transactional sends | --- # SDK guide: Building React Components Source: https://docs.smartlinks.app/docs/sdk/guides/building-react-components Foundational concepts for building React components in the SmartLinks ecosystem. # Building React Components for SmartLinks > **Read this first** before implementing widgets or containers for the SmartLinks platform. This guide covers the fundamental concepts you need to understand to build React components that work correctly in the SmartLinks ecosystem. For implementation details, see [widgets.md](./widgets.md) and [containers.md](./containers.md). --- ## Table of Contents 1. [Dual-Mode Rendering](#dual-mode-rendering) 2. [The Router Contract](#the-router-contract) 3. [The useAppContext Pattern](#the-useappcontext-pattern) 4. [Common Pitfalls](#common-pitfalls) --- ## Dual-Mode Rendering Every SmartLinks component can run in **two modes**. The platform decides which mode to use based on configuration — your app doesn't choose. | Mode | How It Works | When Used | |------|-------------|-----------| | **Direct Component** | Your component runs directly in the parent's React context | Default - better performance, shared context | | **Iframe** | Your app runs inside an iframe with its own URL | Fallback - full isolation when needed | **Key insight:** You write your component code once, and it must work correctly in **both** modes. ### What Changes Between Modes? | Aspect | Direct Component Mode | Iframe Mode | |--------|----------------------|-------------| | **Context source** | Props passed from parent | URL search parameters | | **Router** | Parent provides `MemoryRouter` | Your app provides `HashRouter` | | **Styling** | Inherits parent's CSS scope | Fully isolated CSS | | **SDK instance** | Shared with parent via props | Own instance from `window.SL` | | **Communication** | Direct callbacks (props) | `postMessage` | --- ## The Router Contract This is the **most important rule** to avoid runtime errors: ### ❌ The Critical Rule > **Your exported component (`PublicContainer` or `PublicComponent`) must NOT be wrapped in any `` component.** If you wrap your export in ``, ``, or ``, React Router will throw: ``` Error: You cannot render a inside another ``` ### ✅ Where Routing Goes **For Widgets (no routing needed):** ```tsx // src/exports/PublicComponent.tsx export const PublicComponent = (props) => { return ; // No router needed }; ``` **For Containers (with internal navigation):** ```tsx // src/exports/PublicContainer.tsx import { Routes, Route } from 'react-router-dom'; export const PublicContainer = (props) => { return ( {/* ✅ Routes are fine */} } /> } /> ); }; ``` **For Iframe Entry Point:** ```tsx // src/App.tsx (only used in iframe mode) import { HashRouter, Routes, Route } from 'react-router-dom'; function App() { return ( {/* ✅ HashRouter only in iframe entry point */} } /> ); } ``` ### Why This Matters ``` Direct Component Mode: Portal App └─ MemoryRouter ← Parent provides this └─ Your Component └─ [If you add another Router here, it breaks] Iframe Mode: