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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
143 changes: 143 additions & 0 deletions src/components/Popup/Popup.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
import type { StoryObj, Meta } from "@storybook/web-components"
import { html } from "lit"

import "./Popup"

const meta: Meta = {
title: "Components/Popup",
component: "nosto-popup",
argTypes: {
name: {
control: "text",
description: "Optional name used in analytics and localStorage for persistent closing"
},
segment: {
control: "text",
description: "Optional Nosto segment that acts as a precondition for activation"
}
},
parameters: {
docs: {
description: {
component: `
A popup component that displays content in a dialog with optional ribbon content.
Supports segment-based activation and persistent closure state using localStorage.

## Features
- **Dialog slot**: Main popup content displayed in a centered modal dialog
- **Ribbon slot**: Additional content displayed in bottom-right corner
- **Segment filtering**: Only show popup to users in specified Nosto segments
- **Persistent closure**: Named popups remember when they've been closed
- **Click to close**: Elements with \`n-close\` attribute will close the popup
`
}
}
}
}

export default meta
type Story = StoryObj

export const Default: Story = {
args: {},
render: () => html`
<nosto-popup>
<div slot="default" style="padding: 2rem; background: white; border-radius: 8px; max-width: 400px;">
<h2 style="margin-top: 0;">Special Offer!</h2>
<p>Get 20% off your next purchase when you sign up for our newsletter.</p>
<button
style="background: #007acc; color: white; border: none; padding: 0.75rem 1.5rem; border-radius: 4px; cursor: pointer;"
>
Sign Up Now
</button>
<button
n-close
style="background: transparent; border: 1px solid #ccc; padding: 0.75rem 1.5rem; border-radius: 4px; cursor: pointer; margin-left: 0.5rem;"
>
Close
</button>
</div>
</nosto-popup>
`
}

export const WithRibbon: Story = {
args: {},
render: () => html`
<nosto-popup name="ribbon-example">
<div slot="default" style="padding: 2rem; background: white; border-radius: 8px; max-width: 400px;">
<h2 style="margin-top: 0;">Limited Time Offer</h2>
<p>Don't miss out on this exclusive deal!</p>
<button
style="background: #e74c3c; color: white; border: none; padding: 0.75rem 1.5rem; border-radius: 4px; cursor: pointer;"
>
Shop Now
</button>
<button
n-close
style="background: transparent; border: 1px solid #ccc; padding: 0.75rem 1.5rem; border-radius: 4px; cursor: pointer; margin-left: 0.5rem;"
>
Maybe Later
</button>
</div>
<div
slot="ribbon"
style="background: #f39c12; color: white; padding: 0.75rem 1rem; border-radius: 4px; box-shadow: 0 2px 8px rgba(0,0,0,0.2);"
>
<strong>⏰ Only 2 hours left!</strong>
<button
n-close
style="background: transparent; border: none; color: white; font-size: 1.2rem; cursor: pointer; margin-left: 0.5rem;"
>
Γ—
</button>

Check failure on line 93 in src/components/Popup/Popup.stories.tsx

View workflow job for this annotation

GitHub Actions / copilot

Delete `⏎⏎`
</div>
</nosto-popup>
`
}

export const Named: Story = {
args: {
name: "demo-popup"
},
render: args => html`
<nosto-popup name=${args.name}>
<div slot="default" style="padding: 2rem; background: white; border-radius: 8px; max-width: 400px;">
<h2 style="margin-top: 0;">Named Popup</h2>
<p>This popup has a name and will remember if it's been closed.</p>
<p>
<small>Name: <code>${args.name}</code></small>
</p>
<button
n-close
style="background: #007acc; color: white; border: none; padding: 0.75rem 1.5rem; border-radius: 4px; cursor: pointer;"
>
Close Forever
</button>
</div>
</nosto-popup>
`
}

export const WithSegment: Story = {
args: {
segment: "demo-segment"
},
render: args => html`
<nosto-popup segment=${args.segment}>
<div slot="default" style="padding: 2rem; background: white; border-radius: 8px; max-width: 400px;">
<h2 style="margin-top: 0;">Segment-Based Popup</h2>
<p>This popup only appears for users in the specified segment.</p>
<p>
<small>Required segment: <code>${args.segment}</code></small>
</p>
<button
n-close
style="background: #007acc; color: white; border: none; padding: 0.75rem 1.5rem; border-radius: 4px; cursor: pointer;"
>
Got it!
</button>
</div>
</nosto-popup>
`
}
167 changes: 167 additions & 0 deletions src/components/Popup/Popup.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
import { nostojs } from "@nosto/nosto-js"
import { customElement } from "../decorators"
import { NostoElement } from "../Element"

/**
* A custom element that displays popup content with dialog and ribbon slots.
* Supports conditional activation based on Nosto segments and persistent closure state.
*
* @example
* Basic popup with dialog and ribbon content:
* ```html
* <nosto-popup name="promo-popup" segment="5b71f1500000000000000006">
* <div slot="default">
* <h2>Special Offer!</h2>
* <p>Get 20% off your order today</p>
* <button n-close>Close</button>
* </div>
* <div slot="ribbon">
* <span>Limited time!</span>
* <button n-close>Γ—</button>
* </div>
* </nosto-popup>
* ```
*/
@customElement("nosto-popup")
export class Popup extends NostoElement {
/** @private */
static attributes = {
name: String,
segment: String
}

name?: string
segment?: string

constructor() {
super()
this.attachShadow({ mode: "open" })
}

async connectedCallback() {
// Check if popup was permanently closed
if (this.name && this.isPopupClosed()) {
this.style.display = "none"
return
}

// Check segment precondition if specified
if (this.segment && !(await this.checkSegment())) {
this.style.display = "none"
return
}

this.renderShadowContent()
this.setupEventListeners()
}

disconnectedCallback() {
this.removeEventListeners()
}

private renderShadowContent() {
if (!this.shadowRoot) return
this.shadowRoot.innerHTML = `
<style>
:host {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 1000;
pointer-events: none;
}

dialog {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
border: none;
border-radius: 8px;
padding: 0;
background: transparent;
pointer-events: auto;
z-index: 1001;
}

dialog::backdrop {
background: rgba(0, 0, 0, 0.5);
}

.ribbon {
position: fixed;
bottom: 20px;
right: 20px;
pointer-events: auto;
z-index: 1002;
}

.hidden {
display: none;
}
</style>
<dialog open>
<slot name="default"></slot>
</dialog>
<div class="ribbon">
<slot name="ribbon"></slot>
</div>
`
}

private setupEventListeners() {
this.addEventListener("click", this.handleClick)
}

private removeEventListeners() {
this.removeEventListener("click", this.handleClick)
}

private handleClick = (event: Event) => {
const target = event.target as HTMLElement
if (target?.hasAttribute("n-close")) {
event.preventDefault()
event.stopPropagation()
this.closePopup()
}
}

private closePopup() {
if (this.name) {
this.setPopupClosed()
}
this.style.display = "none"
}

private isPopupClosed(): boolean {
if (!this.name) return false
const key = `nosto:web-components:popup:${this.name}`
return localStorage.getItem(key) === "true"
}

private setPopupClosed() {
if (!this.name) return
const key = `nosto:web-components:popup:${this.name}`
localStorage.setItem(key, "true")
}

private async checkSegment(): Promise<boolean> {
if (!this.segment) return true

try {
const api = await new Promise(nostojs)
const segments = await api.internal.getSegments()
return segments?.includes(this.segment) || false
} catch {
return false
}
}
}

declare global {
interface HTMLElementTagNameMap {
"nosto-popup": Popup
}
}
Loading
Loading