├── .eslintrc ├── .github └── workflows │ └── ci.yml ├── .gitignore ├── .npmignore ├── .prettierrc ├── .vscode ├── extensions.json └── settings.json ├── README.md ├── __tests__ ├── chrome │ ├── events_kit.test-chrome.ts │ ├── incito-publication-kit.test-chrome.ts │ ├── paged-publication-kit.test-chrome.ts │ ├── sgn.test-chrome.html │ ├── sgn.test-chrome.ts │ └── webpack.test-chrome.ts ├── core_kit.test.ts ├── events_kit.test.ts └── translations.test.ts ├── __tests_utils__ ├── style-mock.js └── webpack-compiler.ts ├── docs ├── incito.md └── incito_examples │ ├── css │ └── incito.css │ ├── index-pro.html │ ├── index.html │ └── js │ ├── incito-sample.js │ └── incito.js ├── embeds └── publication-viewer.html ├── esbuild.mjs ├── examples ├── css │ ├── paged-publication.css │ ├── spectre-exp.min.css │ ├── spectre-icons.css │ ├── spectre-icons.min.css │ └── spectre.min.css ├── incito-browser │ ├── example.json │ ├── inner.json │ └── outer.json ├── incito-publication-template.html ├── incito-publication.html ├── incito-viewer.html ├── index.html ├── integrations │ ├── list-paged-publications.html │ └── simple-dealer-latest-publication-viewer.html ├── js │ ├── latlon-geohash.js │ └── paged-publication.js ├── list-publications-template.html ├── paged-publication-template.html ├── paged-publication.html └── verso-browser │ └── verso-browser.html ├── jest.config.json ├── lib ├── config-defaults.ts ├── config.ts ├── incito-browser │ ├── __tests__ │ │ └── incito.test-jsdom.ts │ ├── incito.styl │ ├── incito.ts │ └── types.ts ├── key-codes.ts ├── kits │ ├── core-ui │ │ ├── components │ │ │ ├── common │ │ │ │ ├── header.styl │ │ │ │ ├── header.ts │ │ │ │ ├── index.ts │ │ │ │ ├── menu-popup.styl │ │ │ │ ├── menu-popup.ts │ │ │ │ ├── offer-list.styl │ │ │ │ ├── offer-list.ts │ │ │ │ ├── offer-overview.styl │ │ │ │ ├── offer-overview.ts │ │ │ │ ├── publication-download.ts │ │ │ │ ├── shopping-list.styl │ │ │ │ ├── shopping-list.ts │ │ │ │ └── sidebar.styl │ │ │ ├── helpers │ │ │ │ ├── component.ts │ │ │ │ └── transformers.ts │ │ │ ├── incito-publication │ │ │ │ ├── index.ts │ │ │ │ ├── main-container.styl │ │ │ │ ├── main-container.ts │ │ │ │ ├── section-list.styl │ │ │ │ └── section-list.ts │ │ │ ├── list-publications │ │ │ │ ├── index.ts │ │ │ │ ├── main-container.styl │ │ │ │ └── main-container.ts │ │ │ └── paged-publication │ │ │ │ ├── index.ts │ │ │ │ ├── main-container.styl │ │ │ │ ├── main-container.ts │ │ │ │ ├── page-decoration-list.styl │ │ │ │ ├── page-decoration-list.ts │ │ │ │ ├── page-list.styl │ │ │ │ └── page-list.ts │ │ ├── incito-publication.ts │ │ ├── index.ts │ │ ├── list-publications.ts │ │ ├── offer-details.styl │ │ ├── offer-details.ts │ │ ├── page-decorations.styl │ │ ├── page-decorations.ts │ │ ├── paged-publication.ts │ │ ├── popover.styl │ │ ├── popover.ts │ │ └── single-choice-popover.ts │ ├── core │ │ ├── index.ts │ │ └── request.ts │ ├── events │ │ ├── index.ts │ │ └── tracker.ts │ ├── incito-publication │ │ ├── bootstrapper.ts │ │ ├── controls.ts │ │ ├── event-tracking.ts │ │ ├── index.ts │ │ ├── viewer.styl │ │ ├── viewer.test-jsdom.ts │ │ └── viewer.ts │ └── paged-publication │ │ ├── bootstrapper.ts │ │ ├── controls.ts │ │ ├── core.ts │ │ ├── event-tracking.ts │ │ ├── hotspots.ts │ │ ├── index.ts │ │ ├── page-spread.ts │ │ ├── page-spreads.ts │ │ ├── viewer.styl │ │ └── viewer.ts ├── sgn-sdk.ts ├── storage │ └── client-local.ts ├── stylus │ ├── buttons.styl │ └── sgn.styl ├── tjek-sdk.ts ├── translations.ts ├── util.test.ts ├── util.ts └── verso-browser │ ├── __tests__ │ ├── __snapshots__ │ │ └── smoke.test-node.ts.snap │ ├── smoke.test-node.ts │ └── verso.test-jsdom.ts │ ├── animation.ts │ ├── page_spread.styl │ ├── page_spread.ts │ ├── vendor │ └── hammer │ │ ├── Input.js │ │ ├── Manager.js │ │ ├── Recognizer.js │ │ ├── TouchAction.ts │ │ ├── input │ │ ├── mouse.js │ │ ├── pointerevent.js │ │ ├── touch.js │ │ └── touchmouse.js │ │ ├── recognizers │ │ ├── attribute.js │ │ ├── pan.js │ │ ├── pinch.js │ │ ├── press.js │ │ ├── rotate.js │ │ ├── swipe.js │ │ └── tap.js │ │ └── utils │ │ └── prefixed.ts │ ├── verso.styl │ └── verso.ts ├── locales ├── da_DK.ts ├── en_US.ts ├── index.ts ├── nb_NO.ts └── sv_SE.ts ├── package-lock.json ├── package.json ├── publish-npm.mjs ├── tsconfig.json ├── upload-s3.mjs └── vendor ├── gator.ts └── microevent.ts /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "browser": true, 4 | "jest": true, 5 | "node": true, 6 | "es6": true 7 | }, 8 | "globals": { 9 | "browser": true, 10 | "page": true 11 | }, 12 | "extends": ["eslint:recommended", "prettier"], 13 | "plugins": ["@typescript-eslint", "playwright"], 14 | "parser": "@typescript-eslint/parser", 15 | "parserOptions": {"project": ["./tsconfig.json"]}, 16 | "ignorePatterns": ["examples", "docs", "dist"], 17 | "rules": { 18 | "no-empty": ["error", {"allowEmptyCatch": true}], 19 | "object-shorthand": "warn", 20 | "no-useless-rename": "warn", 21 | "no-var": "warn", 22 | "prefer-const": "warn", 23 | "no-else-return": "warn" 24 | }, 25 | "overrides": [ 26 | { 27 | "files": ["*.ts", "*.tsx"], 28 | "parser": "@typescript-eslint/parser", 29 | "parserOptions": {"project": ["./tsconfig.json"]}, 30 | "extends": [ 31 | "eslint:recommended", 32 | "plugin:@typescript-eslint/recommended", 33 | "plugin:@typescript-eslint/recommended-requiring-type-checking", 34 | "plugin:@typescript-eslint/strict" 35 | ], 36 | "rules": { 37 | "no-useless-escape": "off", 38 | "@typescript-eslint/ban-ts-comment": "off", 39 | "@typescript-eslint/prefer-for-of": "off", 40 | "@typescript-eslint/no-unsafe-member-access": "off", 41 | "@typescript-eslint/no-unsafe-call": "off", 42 | "@typescript-eslint/no-unsafe-assignment": "off", 43 | "@typescript-eslint/no-unsafe-argument": "off", 44 | "@typescript-eslint/no-non-null-assertion": "off", 45 | "@typescript-eslint/no-explicit-any": "off", 46 | "@typescript-eslint/restrict-plus-operands": "off", 47 | "@typescript-eslint/no-unnecessary-type-assertion": "off", 48 | "@typescript-eslint/prefer-nullish-coalescing": "off", 49 | "@typescript-eslint/no-unnecessary-condition": "off", 50 | "@typescript-eslint/prefer-includes": "off", 51 | "@typescript-eslint/no-unsafe-return": "off", 52 | "@typescript-eslint/no-dynamic-delete": "off", 53 | "no-self-assign": "off", 54 | "@typescript-eslint/ban-types": "off", 55 | "@typescript-eslint/no-unnecessary-boolean-literal-compare": "off", 56 | "@typescript-eslint/no-for-in-array": "off", 57 | "@typescript-eslint/no-empty-interface": "off", 58 | "@typescript-eslint/restrict-template-expressions": "off", 59 | "@typescript-eslint/no-empty-function": "off", 60 | "@typescript-eslint/consistent-generic-constructors": "off", 61 | "@typescript-eslint/consistent-type-definitions": "off", 62 | "@typescript-eslint/no-this-alias": "off", 63 | "@typescript-eslint/prefer-optional-chain": "off", 64 | "@typescript-eslint/no-misused-promises": "off", 65 | "@typescript-eslint/no-floating-promises": "off", 66 | "@typescript-eslint/require-await": "off", 67 | "@typescript-eslint/no-unnecessary-type-constraint": "off", 68 | "@typescript-eslint/no-unused-vars": "off", 69 | "@typescript-eslint/no-invalid-void-type": "off", 70 | "@typescript-eslint/consistent-indexed-object-style": "off", 71 | "@typescript-eslint/dot-notation": "off", 72 | "@typescript-eslint/unbound-method": "off", 73 | "prefer-rest-params": "off", 74 | "object-shorthand": "off" 75 | } 76 | } 77 | ] 78 | } 79 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: [push] 3 | jobs: 4 | lint: 5 | runs-on: ubuntu-latest 6 | steps: 7 | - uses: actions/checkout@v4 8 | - uses: actions/setup-node@v4 9 | with: 10 | node-version: 18 11 | cache: 'npm' 12 | - run: npm ci 13 | - run: npm run test:lint 14 | build: 15 | runs-on: ubuntu-latest 16 | steps: 17 | - uses: actions/checkout@v4 18 | - uses: actions/setup-node@v4 19 | with: 20 | node-version: 18 21 | cache: 'npm' 22 | - run: npm ci 23 | - run: npm run build 24 | - uses: actions/upload-artifact@v4 25 | with: 26 | name: built 27 | path: ./dist 28 | retention-days: 1 29 | types: 30 | needs: build 31 | runs-on: ubuntu-latest 32 | steps: 33 | - uses: actions/checkout@v4 34 | - uses: actions/setup-node@v4 35 | with: 36 | node-version: 18 37 | cache: 'npm' 38 | - uses: actions/download-artifact@v4 39 | with: 40 | name: built 41 | path: ./dist 42 | - run: npm ci 43 | - run: npm run test:types 44 | test: 45 | needs: build 46 | runs-on: ubuntu-latest 47 | steps: 48 | - uses: actions/checkout@v4 49 | - uses: actions/setup-node@v4 50 | with: 51 | node-version: 18 52 | cache: 'npm' 53 | - uses: actions/download-artifact@v4 54 | with: 55 | name: built 56 | path: ./dist 57 | 58 | - run: echo "PLAYWRIGHT_VERSION=$(node -e "console.log(require('./package-lock.json').dependencies['@playwright/test'].version)")" >> $GITHUB_ENV 59 | - name: Cache playwright binaries 60 | uses: actions/cache@v4 61 | id: playwright-cache 62 | with: 63 | path: | 64 | ~/.cache/ms-playwright 65 | key: ${{ runner.os }}-playwright-${{ env.PLAYWRIGHT_VERSION }} 66 | - run: npm ci 67 | - run: npx playwright install --with-deps chromium 68 | if: steps.playwright-cache.outputs.cache-hit != 'true' 69 | 70 | - run: npm run test:ci 71 | size: 72 | needs: build 73 | runs-on: ubuntu-latest 74 | steps: 75 | - uses: actions/checkout@v4 76 | - uses: actions/setup-node@v4 77 | with: 78 | node-version: 18 79 | cache: 'npm' 80 | - uses: actions/download-artifact@v4 81 | with: 82 | name: built 83 | path: ./dist 84 | - run: npm ci 85 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | .DS_Store 3 | *.swp 4 | npm-debug.log 5 | dist/ 6 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | .DS_Store 3 | *.swp -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "tabWidth": 4, 4 | "bracketSpacing": false, 5 | "arrowParens": "always", 6 | "trailingComma": "none", 7 | "printWidth": 80 8 | } 9 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": ["dbaeumer.vscode-eslint", "esbenp.prettier-vscode"] 3 | } 4 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "workbench.colorCustomizations": { 3 | "activityBar.background": "#8e273f", 4 | "titleBar.activeBackground": "#62202e", 5 | "titleBar.activeForeground": "#FFF" 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /__tests__/chrome/events_kit.test-chrome.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | 4 | import {coreUrl} from '../../lib/config-defaults'; 5 | import {BaseEvent, WolfEvent} from '../../lib/kits/events/tracker'; 6 | 7 | const ipHtmlUrl = `file:${__dirname}/../../examples/incito-publication.html`; 8 | const ppHtmlUrl = `file:${__dirname}/../../examples/paged-publication.html`; 9 | 10 | describe('SGN.EventsKit', () => { 11 | test('Dispatched Paged Publication Opened events become /sync requests eventually', async () => { 12 | const events: (BaseEvent & WolfEvent)[] = []; 13 | const page = await (await browser.newContext()).newPage(); 14 | page.on('request', (request) => { 15 | if (request.url().endsWith('/sync')) { 16 | events.push(...request.postDataJSON().events); 17 | } 18 | }); 19 | await page.goto(ppHtmlUrl); 20 | 21 | await page.waitForLoadState('load'); 22 | await page.waitForTimeout(8000); 23 | 24 | expect(events.length).toBeGreaterThan(0); 25 | expect(events.filter((e) => e._e === 1).length).toBe(1); 26 | }); 27 | test('Dispatched Incito Publication Opened events become /sync requests eventually', async () => { 28 | const response = await fetch( 29 | coreUrl + '/v2/catalogs?limit=1&types=incito' 30 | ); 31 | const id = (await response.json())?.[0]?.id; 32 | 33 | const events: (BaseEvent & WolfEvent)[] = []; 34 | const page = await (await browser.newContext()).newPage(); 35 | page.on('request', (req) => { 36 | if (req.url().endsWith('/sync')) { 37 | events.push(...req.postDataJSON().events); 38 | } 39 | }); 40 | await page.goto(`${ipHtmlUrl}?id=${id}`); 41 | 42 | await page.waitForLoadState('load'); 43 | await page.waitForTimeout(8000); 44 | 45 | expect(events.length).toBeGreaterThan(0); 46 | expect(events.filter(({_e}) => _e === 11).length).toBe(1); 47 | }); 48 | test('Dispatched Paged Publication Opened events become /sync requests on page close', async () => { 49 | const events: (BaseEvent & WolfEvent)[] = []; 50 | const page = await (await browser.newContext()).newPage(); 51 | page.on('request', (interceptedRequest) => { 52 | if (interceptedRequest.url().endsWith('/sync')) { 53 | events.push(...interceptedRequest.postDataJSON().events); 54 | } 55 | }); 56 | await page.goto(ppHtmlUrl); 57 | await page.waitForLoadState('load'); 58 | await page.waitForTimeout(1000); 59 | await page.close({runBeforeUnload: true}); 60 | await new Promise((y) => setTimeout(y, 1000)); 61 | 62 | expect(events.length).toBeGreaterThan(0); 63 | expect(events.filter(({_e}) => _e === 1).length).toBe(1); 64 | }); 65 | test('Dispatched Incito Publication Opened events become /sync requests on page close', async () => { 66 | const response = await fetch( 67 | coreUrl + '/v2/catalogs?limit=1&types=incito' 68 | ); 69 | const id = (await response.json())?.[0]?.id; 70 | 71 | const eventsSent: (BaseEvent & WolfEvent)[] = []; 72 | const page = await (await browser.newContext()).newPage(); 73 | page.on('request', (interceptedRequest) => { 74 | if (interceptedRequest.url().endsWith('/sync')) { 75 | eventsSent.push(...interceptedRequest.postDataJSON().events); 76 | } 77 | }); 78 | await page.goto(`${ipHtmlUrl}?id=${id}`); 79 | await page.waitForLoadState('load'); 80 | await page.waitForTimeout(1000); 81 | await page.close({runBeforeUnload: true}); 82 | await new Promise((y) => setTimeout(y, 1000)); 83 | 84 | expect(eventsSent.length).toBeGreaterThan(0); 85 | expect(eventsSent.filter(({_e}) => _e === 11).length).toBe(1); 86 | }); 87 | }); 88 | -------------------------------------------------------------------------------- /__tests__/chrome/incito-publication-kit.test-chrome.ts: -------------------------------------------------------------------------------- 1 | import fetch from 'cross-fetch'; 2 | 3 | const IPPath = `file:${__dirname}/../../examples/incito-publication.html`; 4 | 5 | const newIncognitoPage = async (route) => { 6 | const page = await (await browser.newContext()).newPage(); 7 | await page.goto(route); 8 | return page; 9 | }; 10 | 11 | describe('Chrome: Incito Publication', () => { 12 | let id; 13 | beforeAll(async () => { 14 | const response = await fetch( 15 | 'https://squid-api.tjek.com/v2/catalogs?limit=1&types=incito' 16 | ); 17 | id = (await response.json())?.[0]?.id; 18 | }); 19 | it('Example html loads', async () => { 20 | const page = await newIncognitoPage(`${IPPath}?id=${id}`); 21 | 22 | await expect(page).toHaveSelector('.sgn__incito'); 23 | }); 24 | it('Viewer mounts', async () => { 25 | const page = await newIncognitoPage(`${IPPath}?id=${id}`); 26 | 27 | await expect(page).toHaveSelector('.incito__view'); 28 | }); 29 | }); 30 | -------------------------------------------------------------------------------- /__tests__/chrome/paged-publication-kit.test-chrome.ts: -------------------------------------------------------------------------------- 1 | const PPPath = `file:${__dirname}/../../examples/paged-publication.html`; 2 | 3 | const newIncognitoPage = async (route) => { 4 | const page = await (await browser.newContext()).newPage(); 5 | await page.goto(route); 6 | return page; 7 | }; 8 | 9 | describe('Chrome: Paged Publication', () => { 10 | it('Should display "Make your PDF alive with Tjek." text on page', async () => { 11 | const page = await newIncognitoPage(PPPath); 12 | 13 | await new Promise((r) => setTimeout(r, 3000)); 14 | 15 | expect( 16 | await page.$eval('body', (el) => 17 | /Make your PDF alive with Tjek./.test(el.innerText) 18 | ) 19 | ).toBe(true); 20 | }); 21 | 22 | it('Should load with the intro active', async () => { 23 | const page = await newIncognitoPage(PPPath); 24 | 25 | await expect(page).toHaveSelector('#intro[data-active="true"]'); 26 | }); 27 | 28 | it('Should go to next page when clicking the next page thingie', async () => { 29 | const page = await newIncognitoPage(PPPath); 30 | 31 | await expect(page).toHaveSelector('#intro[data-active="true"]'); 32 | await page.click('.sgn-pp__control[data-direction="next"]'); 33 | await expect(page).not.toHaveSelector('#intro[data-active="true"]'); 34 | await expect(page).toHaveSelector( 35 | '[data-active="true"][data-id="double-0"]' 36 | ); 37 | }); 38 | }); 39 | -------------------------------------------------------------------------------- /__tests__/chrome/sgn.test-chrome.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /__tests__/chrome/sgn.test-chrome.ts: -------------------------------------------------------------------------------- 1 | import {join} from 'path'; 2 | import type {EventTracker} from '../../lib/tjek-sdk'; 3 | 4 | declare global { 5 | interface Window { 6 | SGN: typeof import('./../../lib/sgn-sdk'); 7 | } 8 | } 9 | 10 | describe('Chrome: SGN singleton behavior', () => { 11 | let page; 12 | beforeEach(async () => { 13 | page = await browser.newPage(); 14 | page.on('console', (consoleObj) => 15 | console.log('console:', consoleObj.text()) 16 | ); 17 | page.on('pageerror', (err) => 18 | console.log('Page error:', err.toString()) 19 | ); 20 | page.on('error', (err) => console.log('Error:', err.toString())); 21 | await page.goto(`file:${join(__dirname, './sgn.test-chrome.html')}`); 22 | }); 23 | it('Magic API key config from script tag', async () => { 24 | const srcAppKey = await page.evaluate( 25 | () => 26 | document.querySelector('[data-api-key]')?.dataset 27 | .apiKey 28 | ); 29 | const cfgAppKey = await page.evaluate(() => 30 | window.SGN.config.get('appKey') 31 | ); 32 | expect(srcAppKey).toMatch(cfgAppKey); 33 | }); 34 | it('Magic EventTracker creation & config from script tag', async () => { 35 | const srcTrackId = await page.evaluate( 36 | () => 37 | document.querySelector('[data-track-id]')?.dataset 38 | .trackId 39 | ); 40 | const cfgTrackId = await page.evaluate( 41 | () => window.SGN.config.get('eventTracker')?.trackId 42 | ); 43 | 44 | expect(srcTrackId).toMatch(cfgTrackId); 45 | }); 46 | }); 47 | -------------------------------------------------------------------------------- /__tests__/chrome/webpack.test-chrome.ts: -------------------------------------------------------------------------------- 1 | import {readFile} from 'fs/promises'; 2 | import {globSync} from 'glob'; 3 | import {dirname, resolve} from 'path'; 4 | import webpackCompiler from '../../__tests_utils__/webpack-compiler'; 5 | 6 | const buildMatrix = ( 7 | options: A[][], 8 | index = 0, 9 | results: A[][] = [], 10 | current: A[] = [] 11 | ) => { 12 | options[index].forEach((val) => { 13 | current[index] = val; 14 | if (index + 1 < options.length) { 15 | buildMatrix(options, index + 1, results, current); 16 | } else results.push([...current]); 17 | }); 18 | 19 | return results; 20 | }; 21 | 22 | const packages = globSync('./dist/**/*/package.json'); 23 | const modes = ['development', 'production'] as const; 24 | for (const path of packages) { 25 | for (const mode of modes) { 26 | test(`Webpack + Chrome: ${path} ${mode}`, async () => { 27 | const packageJson = JSON.parse(await readFile(path, 'utf-8')); 28 | const scriptPath = resolve(dirname(path), packageJson.module); 29 | const code = `import * as A from '${scriptPath}'; console.log(A)`; 30 | 31 | await page.evaluate(await webpackCompiler(code, {mode})); 32 | }); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /__tests__/core_kit.test.ts: -------------------------------------------------------------------------------- 1 | import * as SGN from '../'; 2 | 3 | const apiKey = '00j4o5wpwptl84fuubdig2s6ej5uyna8'; 4 | 5 | SGN.config.set({ 6 | apiKey 7 | }); 8 | 9 | describe('SGN.CoreKit', () => { 10 | test('Making a request with JSON response', async () => { 11 | const data = await SGN.CoreKit.request({url: '/v2/catalogs'}); 12 | 13 | expect(data).toBeDefined(); 14 | expect(typeof data).toBe('object'); 15 | }); 16 | }); 17 | -------------------------------------------------------------------------------- /__tests__/events_kit.test.ts: -------------------------------------------------------------------------------- 1 | import * as SGN from '../'; 2 | 3 | describe('SGN.EventsKit', () => { 4 | test('Can create a view token', () => { 5 | const tracker = new SGN.EventsKit.Tracker({ 6 | trackId: 'AAABrQ==', 7 | client: {id: 'selfmade'} 8 | }); 9 | 10 | expect(tracker.createViewToken('test', 1)).toEqual('29g0Lh6ViFc='); 11 | expect(tracker.createViewToken('go', 'go', 2, 'nice', 'øl')).toEqual( 12 | 'nAu6OWTIWnc=' 13 | ); 14 | expect(tracker.createViewToken('🌈')).toEqual('Pdz8/0+PiYk='); 15 | }); 16 | 17 | test('Can dispatch', () => { 18 | const id = '3395WdY'; 19 | const tracker = new SGN.EventsKit.Tracker({trackId: 'AAABrQ=='}); 20 | 21 | tracker.trackPagedPublicationOpened({ 22 | 'pp.id': id, 23 | vt: tracker.createViewToken(id) 24 | }); 25 | // tracker.ship tracker.getPool(), (err, res) -> 26 | // expect(res).toBeDefined() 27 | // expect(res.events.length).toEqual 1 28 | // expect(res.events[0].status).toEqual 'ack' 29 | 30 | // done() 31 | 32 | // return 33 | }); 34 | }); 35 | -------------------------------------------------------------------------------- /__tests__/translations.test.ts: -------------------------------------------------------------------------------- 1 | import * as SGN from '../'; 2 | 3 | describe('SGN.translations', () => { 4 | test('Can translate', () => { 5 | expect(typeof SGN.translations.t('some_key')).toEqual('string'); 6 | expect(typeof SGN.translations.t('some_key')).toEqual('string'); 7 | }); 8 | 9 | test('Can update translations', () => { 10 | expect(SGN.translations.t('non_existing_key')).toEqual(''); 11 | 12 | SGN.translations.update({non_existing_key: 'test'}); 13 | 14 | expect(SGN.translations.t('non_existing_key')).toEqual('test'); 15 | }); 16 | }); 17 | -------------------------------------------------------------------------------- /__tests_utils__/style-mock.js: -------------------------------------------------------------------------------- 1 | module.exports = {}; 2 | -------------------------------------------------------------------------------- /__tests_utils__/webpack-compiler.ts: -------------------------------------------------------------------------------- 1 | // This implemenation llifted from https://stackoverflow.com/q/38779924 2 | 3 | import root from 'app-root-path'; 4 | import fs from 'fs'; 5 | import {createFsFromVolume} from 'memfs'; 6 | import {Volume} from 'memfs/lib/volume'; 7 | import path from 'path'; 8 | import webpack, {Configuration, Stats} from 'webpack'; 9 | 10 | const memFs = createFsFromVolume(new Volume()); 11 | 12 | /* 13 | * Provide webpack with an instance of MemoryFS for 14 | * in-memory compilation. We're currently overriding 15 | * #stat and #readFile. Webpack will ask MemoryFS for the 16 | * entry file, which it will find successfully. However, 17 | * all dependencies are on the real filesystem, so any require 18 | * or import statements will fail. When that happens, our wrapper 19 | * functions will then check fs for the requested file. 20 | */ 21 | const stat: Volume['stat'] = memFs.stat.bind(memFs); 22 | const readFile: Volume['readFile'] = memFs.readFile.bind(memFs); 23 | 24 | // @ts-expect-error Whiny tiny mismatch in a private property 25 | memFs.stat = ( 26 | path: string, 27 | callback: (err: NodeJS.ErrnoException | null, stats: fs.Stats) => void 28 | ) => 29 | stat(path, (err, res) => 30 | err || !res 31 | ? fs.stat(path, callback) 32 | : callback(err || null, res as fs.Stats) 33 | ); 34 | 35 | // @ts-expect-error 36 | memFs.readFile = ( 37 | path: string, 38 | cb: (err: NodeJS.ErrnoException | null, data: Buffer) => void 39 | ) => 40 | readFile(path, (err, res) => 41 | err || !res ? fs.readFile(path, cb) : cb(err || null, res as Buffer) 42 | ); 43 | 44 | const filename = 'file.js'; 45 | export default async function compile( 46 | code: string, 47 | config: Configuration = {} 48 | ): Promise { 49 | // Setup webpack 50 | //create a directory structure in MemoryFS that matches 51 | //the real filesystem 52 | const rootDir = root.toString(); 53 | if (!memFs.existsSync(rootDir)) memFs.mkdirSync(rootDir, {recursive: true}); 54 | 55 | const entry = path.join(rootDir, filename); 56 | //write code snippet to memoryfs 57 | memFs.writeFileSync(entry, code); 58 | //point webpack to memoryfs for the entry file 59 | const compiler = webpack({ 60 | entry, 61 | ...config, 62 | output: {filename, ...config.output} 63 | }); 64 | 65 | //direct webpack to use memoryfs 66 | // @ts-expect-error - it's not that serious 67 | compiler.inputFileSystem = compiler.outputFileSystem = memFs; 68 | 69 | const errors = ( 70 | await new Promise((y, n) => 71 | compiler.run((e, r) => (e ? n(e) : y(r!))) 72 | ) 73 | ).compilation.errors; 74 | 75 | //if there are errors, throw the first one 76 | if (errors?.length) throw errors[0]; 77 | 78 | //retrieve the output of the compilation 79 | return String( 80 | memFs.readFileSync(path.join(rootDir, 'dist', filename), 'utf8') 81 | ); 82 | } 83 | -------------------------------------------------------------------------------- /docs/incito_examples/css/incito.css: -------------------------------------------------------------------------------- 1 | #incito__details { 2 | background: black; 3 | display: none; 4 | position: absolute; 5 | max-height: 100px; 6 | z-index: 1; 7 | box-shadow: 3px 3px 3px 3px #00000000; 8 | border-width: 0px 4px 4px 4px; 9 | border-style: solid; 10 | border-color: black; 11 | padding: 1px 7px 1px 7px; 12 | } 13 | 14 | .incito__details__title { 15 | font-size: 17; 16 | padding: 3px 0; 17 | color: white; 18 | } 19 | 20 | .incito__details__description { 21 | font-size: 13; 22 | padding: 3px 0; 23 | color: #ffffffba; 24 | } 25 | 26 | .incito__details__button { 27 | font-family: consolas; 28 | padding: 3px 0; 29 | text-decoration: none; 30 | color: rgb(255, 85, 0); 31 | } -------------------------------------------------------------------------------- /docs/incito_examples/index-pro.html: -------------------------------------------------------------------------------- 1 | 2 | Incito 3 | 7 | 8 | 13 | 14 | 15 | 16 | 22 | 23 | 26 | 37 | 38 | 39 | -------------------------------------------------------------------------------- /docs/incito_examples/index.html: -------------------------------------------------------------------------------- 1 | 2 | Incito 3 | 7 | 8 | 9 | 10 | 16 | 17 | 20 |
21 |
22 |
23 |
24 | 25 | 26 | -------------------------------------------------------------------------------- /docs/incito_examples/js/incito-sample.js: -------------------------------------------------------------------------------- 1 | (function () { 2 | // TODO: Set your business id 3 | var businessId = 'YOUR_BUSINESS_ID'; 4 | 5 | var loadIncito = function () { 6 | var incitoRootElement = document.querySelector('#incito__publication'); 7 | 8 | fetchPublications(function (err, publications) { 9 | if (!err) { 10 | if (publications[0]) { 11 | openIncitoPublication(publications[0], incitoRootElement); 12 | } else { 13 | alert('No Incito publications found!'); 14 | } 15 | } else { 16 | console.error('Error fetching publications:'); 17 | console.error(err); 18 | } 19 | }); 20 | }; 21 | 22 | var incitoPublicationViewer; 23 | var fetchPublications = function (callback) { 24 | SGN.CoreKit.request( 25 | { 26 | url: '/v2/catalogs', 27 | qs: { 28 | dealer_id: businessId, 29 | order_by: '-valid_date', 30 | types: 'incito', 31 | offset: 0, 32 | limit: 4 33 | } 34 | }, 35 | callback 36 | ); 37 | }; 38 | 39 | var openIncitoPublication = function (publication, incitoRootElement) { 40 | var incitoPublication = new SGN.IncitoPublicationKit.Bootstrapper({ 41 | el: incitoRootElement, 42 | id: publication.id 43 | }); 44 | 45 | incitoPublication.fetchIncito( 46 | publication.incito_publication_id, 47 | function (err, incito) { 48 | if (!err) { 49 | incitoPublicationViewer = incitoPublication.createViewer({ 50 | details: publication, 51 | incito: incito 52 | }); 53 | 54 | incitoPublicationViewer.bind( 55 | 'progress', 56 | function (navEvent) { 57 | console.info( 58 | 'loading progress ' + 59 | Math.ceil(navEvent.progress) + 60 | '%' 61 | ); 62 | } 63 | ); 64 | 65 | incitoPublicationViewer.start(); 66 | } else { 67 | console.error('Error loading incito:'); 68 | console.error(err); 69 | } 70 | } 71 | ); 72 | }; 73 | 74 | window.shopgun = {loadIncito: loadIncito}; 75 | })(); 76 | -------------------------------------------------------------------------------- /examples/css/paged-publication.css: -------------------------------------------------------------------------------- 1 | iframe { 2 | border: 0; 3 | } 4 | 5 | .container a { 6 | color: #fff; 7 | text-decoration: underline; 8 | } 9 | 10 | .section--air { 11 | margin-bottom: 70px; 12 | } 13 | 14 | .page .container { 15 | padding-top: 100px; 16 | } 17 | 18 | #intro .timeline { 19 | margin-top: 30px; 20 | max-width: 600px; 21 | } 22 | 23 | #outro .video-responsive { 24 | margin-bottom: 20px; 25 | } 26 | 27 | .btn.btn-primary, 28 | .btn.btn-primary:hover { 29 | background: #000; 30 | border-color: #000; 31 | } 32 | 33 | @media (max-width: 600px) { 34 | #outro .container { 35 | padding-top: 50px; 36 | } 37 | 38 | h1 { 39 | font-size: 3rem; 40 | } 41 | 42 | h2 { 43 | font-size: 2.5rem; 44 | } 45 | 46 | h4 { 47 | font-size: 2rem; 48 | } 49 | } -------------------------------------------------------------------------------- /examples/incito-browser/inner.json: -------------------------------------------------------------------------------- 1 | { 2 | "view_name": "ImageView", 3 | "src": "https://seadragon-api.tjek.com/resize?type=jpeg&url=s3%3A%2F%2Fraven-production%2Fmedia%2Fcatalogs%2Fda_DK%2Fw9Q-06NT%2Foffers%2F9353089.jpg&width=600&sign=EDSJ9xzm8XQWXcQlP8GetY2CWJEPFVGSiUqzloVqOEc" 4 | } -------------------------------------------------------------------------------- /examples/incito-browser/outer.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "PRvgF9NV", 3 | "version": "1.0.0", 4 | "theme": { 5 | "font_family": [ 6 | "HelveticaNeue-CondensedBold", 7 | "HelveticaNeueBoldCondensed", 8 | "HelveticaNeue-Bold-Condensed", 9 | "Helvetica Neue Bold Condensed", 10 | "HelveticaNeue", 11 | "Helvetica Neue", 12 | "TeXGyreHerosCnBold", 13 | "Helvetica", 14 | "Tahoma", 15 | "Geneva", 16 | "Arial Narrow", 17 | "Arial", 18 | "sans-serif" 19 | ], 20 | "background_color": "#ffffff", 21 | "text_color": "#000000", 22 | "line_spacing_multiplier": 1.4, 23 | "style": ".incito__incito-embed-view { width: 500px; height: 500px; border: 1px solid red; }" 24 | }, 25 | "locale": "da_DK", 26 | "root_view": { 27 | "child_views": [{ 28 | "view_name": "IncitoEmbedView", 29 | "src": "/examples/incito-browser/inner.json" 30 | }, { 31 | "view_name": "IncitoEmbedView", 32 | "src": "/examples/incito-browser/inner.json" 33 | }, { 34 | "view_name": "IncitoEmbedView", 35 | "src": "/examples/incito-browser/inner.json" 36 | }, { 37 | "view_name": "IncitoEmbedView", 38 | "src": "/examples/incito-browser/inner.json" 39 | }, { 40 | "view_name": "IncitoEmbedView", 41 | "src": "/examples/incito-browser/inner.json" 42 | }, { 43 | "view_name": "IncitoEmbedView", 44 | "src": "/examples/incito-browser/inner.json" 45 | }, { 46 | "view_name": "IncitoEmbedView", 47 | "src": "/examples/incito-browser/inner.json" 48 | }, { 49 | "view_name": "IncitoEmbedView", 50 | "src": "/examples/incito-browser/inner.json" 51 | }, { 52 | "view_name": "IncitoEmbedView", 53 | "src": "/examples/incito-browser/inner.json" 54 | }, { 55 | "view_name": "IncitoEmbedView", 56 | "src": "/examples/incito-browser/inner.json" 57 | }, { 58 | "view_name": "IncitoEmbedView", 59 | "src": "/examples/incito-browser/inner.json" 60 | }] 61 | } 62 | } -------------------------------------------------------------------------------- /examples/incito-publication-template.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Complete Incito Publication 5 | 6 | 7 | 8 | 9 | 14 | 15 | 20 | 21 | 22 |
23 | 24 | 28 | 29 | 33 | 34 | 38 | 39 | 43 | 44 | 48 | 49 | 71 | 72 | 87 | 88 | 89 | -------------------------------------------------------------------------------- /examples/incito-viewer.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Incito Viewer 5 | 6 | 10 | 11 | 12 | 17 | 28 | 29 | 30 |
31 | 32 | 38 | 51 | 52 | 53 | -------------------------------------------------------------------------------- /examples/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Tjek JavaScript SDK 6 | 7 | 8 | 9 |

Kits

10 | 21 |

Packages

22 | 30 | 31 | 32 | 33 | -------------------------------------------------------------------------------- /examples/integrations/simple-dealer-latest-publication-viewer.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 8 | 9 | 10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 | 27 | 34 |
35 | 41 | 71 | 72 | 73 | -------------------------------------------------------------------------------- /examples/js/paged-publication.js: -------------------------------------------------------------------------------- 1 | (function () { 2 | var id = SGN.util.getQueryParam('id'); 3 | var pageNo = SGN.util.getQueryParam('pageNo'); 4 | var options = { 5 | el: document.querySelector('.sgn__pp'), 6 | eventTracker: SGN.config.get('eventTracker') 7 | }; 8 | var start = function () { 9 | var bootstrapper = new SGN.PagedPublicationKit.Bootstrapper(options); 10 | 11 | bootstrapper.fetch(function (err, data) { 12 | if (!err) { 13 | var viewer = bootstrapper.createViewer(data, { 14 | pageId: pageNo ? 'page' + pageNo : undefined 15 | }); 16 | 17 | viewer.bind('hotspotsPointerdown', function (hotspots) { 18 | console.log('hotspotsPointerdown', hotspots); 19 | }); 20 | viewer.bind('hotspotClicked', function (hotspot) { 21 | console.log('Hotspot clicked', hotspot); 22 | 23 | // For example, if you want to redirect to a webshop link if one is set on the offer 24 | if (hotspot.webshop) { 25 | window.location = hotspot.webshop; 26 | } 27 | }); 28 | 29 | viewer.bind('hotspotPressed', function (hotspot) { 30 | console.log('Hotspot pressed', hotspot); 31 | }); 32 | 33 | viewer.bind('hotspotContextmenu', function (hotspot) { 34 | console.log('Hotspot contextmenu', hotspot); 35 | }); 36 | 37 | viewer.start(); 38 | 39 | // Fetch hotspots after rendering the viewer as they are not critical for initial render. 40 | bootstrapper.fetchHotspots(function (err2, hotspots) { 41 | if (!err2) { 42 | bootstrapper.applyHotspots(viewer, hotspots); 43 | } 44 | }); 45 | 46 | // Optionally fetch and apply decorations after rendering the viewer as they are not critical for initial render. 47 | bootstrapper.fetchPageDecorations(function (err3, decorations) { 48 | if (!err3) { 49 | bootstrapper.applyPageDecorations(viewer, decorations); 50 | } 51 | }); 52 | } else { 53 | console.error(err); 54 | } 55 | }); 56 | }; 57 | 58 | if (id) { 59 | options.id = id; 60 | 61 | start(); 62 | } else { 63 | SGN.CoreKit.request( 64 | { 65 | url: '/v2/catalogs', 66 | qs: { 67 | limit: 1 68 | } 69 | }, 70 | function (err, catalogs) { 71 | if (!err) { 72 | options.id = catalogs[0].id; 73 | 74 | start(); 75 | } 76 | } 77 | ); 78 | } 79 | })(); 80 | -------------------------------------------------------------------------------- /examples/list-publications-template.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | List Publications 5 | 6 | 7 | 8 | 9 | 14 | 15 | 20 | 21 | 22 | 23 |
24 | 25 | 29 | 30 | 60 | 61 | 62 | 69 | 70 | 71 | 78 | 79 | 80 | 87 | 88 | 89 | -------------------------------------------------------------------------------- /examples/paged-publication-template.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Complete Paged Publication 5 | 6 | 7 | 8 | 9 | 14 | 15 | 20 | 21 | 22 |
23 | 24 | 28 | 29 | 33 | 34 | 38 | 39 | 43 | 44 | 48 | 49 | 73 | 74 | 75 | -------------------------------------------------------------------------------- /jest.config.json: -------------------------------------------------------------------------------- 1 | { 2 | "testPathIgnorePatterns": ["node_modules"], 3 | "testTimeout": 30000, 4 | "projects": [ 5 | { 6 | "displayName": "Node", 7 | "testEnvironment": "node", 8 | "testRegex": "((\\.|/)(test|test-node))\\.(j|t)s$", 9 | "transform": {"\\.(j|t)s$": "jest-esbuild"}, 10 | "moduleNameMapper": { 11 | "\\.(css|styl)$": "/__tests_utils__/style-mock.js" 12 | } 13 | }, 14 | { 15 | "displayName": "JSDom", 16 | "transform": {"\\.(j|t)s$": "jest-esbuild"}, 17 | "testEnvironment": "jsdom", 18 | "testRegex": "((\\.|/)(test|test-jsdom))\\.(j|t)s$", 19 | "moduleNameMapper": { 20 | "\\.(css|styl)$": "/__tests_utils__/style-mock.js" 21 | } 22 | }, 23 | { 24 | "preset": "jest-playwright-preset", 25 | "transform": {"\\.(j|t)s$": "jest-esbuild"}, 26 | "testRegex": "((\\.|/)(test-chrome))\\.(j|t)s$" 27 | } 28 | ] 29 | } 30 | -------------------------------------------------------------------------------- /lib/config-defaults.ts: -------------------------------------------------------------------------------- 1 | export const coreUrl = 'https://squid-api.tjek.com'; 2 | export const coreUrlStaging = 'https://squid-api.tjek-staging.com'; 3 | export const eventsTrackUrl = 'https://wolf-api.tjek.com/sync'; 4 | export const eventsTrackUrlStaging = 'https://wolf-api.tjek-staging.com/sync'; 5 | -------------------------------------------------------------------------------- /lib/config.ts: -------------------------------------------------------------------------------- 1 | import MicroEvent from '../vendor/microevent'; 2 | import * as configDefaults from './config-defaults'; 3 | 4 | class Config extends MicroEvent<{change: [Record]}> { 5 | keys = ['apiKey', 'eventTracker', 'coreUrl', 'eventsTrackUrl'] as const; 6 | _attrs = {...configDefaults}; 7 | 8 | set(config: Record) { 9 | const changedAttributes = {}; 10 | 11 | for (let key in config) { 12 | if (key === 'appKey') key = 'apiKey'; 13 | if (this.keys.includes(key as (typeof this.keys)[number])) { 14 | this._attrs[key] = config[key]; 15 | changedAttributes[key] = config[key]; 16 | } 17 | } 18 | 19 | this.trigger('change', changedAttributes); 20 | } 21 | 22 | get(option: string): T { 23 | if (option === 'appKey') option = 'apiKey'; 24 | return this._attrs[option]; 25 | } 26 | 27 | shadow = Record>( 28 | optionsObject?: T 29 | ): T & Record<(typeof this.keys)[number], string> { 30 | const optionsWithConfig = {...optionsObject}; 31 | this.keys.forEach((key) => { 32 | const get = () => optionsObject?.[key] || this.get(key); 33 | Object.defineProperty(optionsWithConfig, key, {get}); 34 | }); 35 | 36 | return optionsWithConfig as T & 37 | Record<(typeof this.keys)[number], string>; 38 | } 39 | } 40 | 41 | export default Config; 42 | -------------------------------------------------------------------------------- /lib/incito-browser/__tests__/incito.test-jsdom.ts: -------------------------------------------------------------------------------- 1 | import data from '../../../examples/incito-browser/example.json'; 2 | import Incito from '../incito'; 3 | import type {IIncito} from '../types'; 4 | 5 | describe('Incito', () => { 6 | it('should handle valid JSON and start without throwing', (done) => { 7 | document.body.innerHTML = '
'; 8 | 9 | const main = document.getElementById('main')!; 10 | 11 | // @ts-expect-error can't treat the json import as const 🤷‍♀️ 12 | const incito: IIncito = data; 13 | const incitoViewer = new Incito(main, {incito}); 14 | 15 | expect(() => incitoViewer.start()).not.toThrow(); 16 | 17 | done(); 18 | }); 19 | }); 20 | -------------------------------------------------------------------------------- /lib/incito-browser/incito.styl: -------------------------------------------------------------------------------- 1 | .incito 2 | position relative 3 | font-family -apple-system, system-ui, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif 4 | box-sizing border-box 5 | text-rendering optimizeLegibility 6 | 7 | *:before, 8 | *, 9 | *:after 10 | box-sizing inherit 11 | 12 | [data-link] 13 | cursor pointer 14 | 15 | .incito__view 16 | transform-origin 0 0 17 | overflow hidden 18 | margin 0 19 | padding 0 20 | display block 21 | background-repeat no-repeat 22 | border 0 23 | 24 | &[data-gravity="center_horizontal"] 25 | margin-left auto !important 26 | margin-right auto !important 27 | 28 | &[data-gravity="left_horizontal"] 29 | margin-right auto !important 30 | 31 | &[data-gravity="right_horizontal"] 32 | margin-left auto !important 33 | 34 | .incito__video-embed-view > iframe 35 | width 100% 36 | height 100% 37 | border 0 38 | 39 | .incito__text-view 40 | margin 0 41 | font-family inherit 42 | word-break break-word 43 | overflow-wrap break-word 44 | word-wrap break-word 45 | text-overflow ellipsis 46 | -moz-osx-font-smoothing grayscale 47 | font-smoothing antialiased 48 | 49 | a 50 | color inherit 51 | text-decoration underline 52 | 53 | &[data-single-line="true"] 54 | white-space nowrap 55 | 56 | [data-name="superscript"] 57 | vertical-align 0.5em 58 | position relative 59 | font-size 0.6em 60 | 61 | .incito__image-view 62 | border none 63 | box-shadow none 64 | 65 | .incito__absolute-layout-view 66 | position relative 67 | 68 | > * 69 | position absolute 70 | 71 | .incito__carousel-layout-view 72 | display flex 73 | flex-direction row 74 | justify-content flex-start 75 | align-items center 76 | overflow hidden 77 | 78 | .incito--visible .incito__carousel-layout-view 79 | overflow-x auto 80 | scroll-snap-type x mandatory 81 | scroll-snap-stop always 82 | 83 | .incito__carousel-layout-view > * 84 | flex 0 0 auto 85 | scroll-snap-align start 86 | display flex 87 | justify-content center 88 | align-items center 89 | box-sizing border-box 90 | 91 | .incito__carousel-layout-view::-webkit-scrollbar 92 | display none 93 | 94 | .incito__video-view 95 | max-width unset -------------------------------------------------------------------------------- /lib/key-codes.ts: -------------------------------------------------------------------------------- 1 | export const ESC = 27; 2 | export const ARROW_RIGHT = 39; 3 | export const ARROW_LEFT = 37; 4 | export const SPACE = 32; 5 | export const NUMBER_ONE = 49; 6 | export const ENTER = 'Enter'; 7 | -------------------------------------------------------------------------------- /lib/kits/core-ui/components/common/header.styl: -------------------------------------------------------------------------------- 1 | .sgn__header 2 | position absolute 3 | display none 4 | z-index 99 5 | height 80px 6 | margin auto 7 | width 100% 8 | text-align center 9 | animation-duration .3s 10 | animation-iteration-count 1 11 | animation-name sgn-animate-header 12 | animation-timing-function ease 13 | 14 | > .sgn__nav 15 | position relative 16 | display inline-block 17 | border-radius 12px 18 | margin-top 8px 19 | overflow hidden 20 | box-shadow rgba(0, 0, 0, 0.4) 0px 5px 16px, rgba(0, 0, 0, 0.2) 0px 0px 1px 21 | text-align center 22 | color inherit 23 | 24 | .sgn__nav-content 25 | padding 8px 8px 2px 26 | 27 | > button 28 | color inherit 29 | background-color transparent 30 | display inline-block 31 | vertical-align middle 32 | padding 2px 33 | border 0 34 | cursor pointer 35 | width 50px 36 | 37 | > svg 38 | display inline 39 | width 25px 40 | height 25px 41 | 42 | .sgn__nav-label 43 | display none 44 | font-size 10px 45 | 46 | .sgn__offer-shopping-list-counter 47 | display block 48 | position absolute 49 | margin-left 30px 50 | margin-top -34px 51 | padding 1px 52 | background #f3f3f9 53 | color #202020 54 | border-radius 10px 55 | font-size 11px 56 | box-shadow 1px 2px rgba(0, 0, 0, 0.3) 57 | width 17px 58 | height 17px 59 | line-height 1.5 60 | animation-duration .25s 61 | animation-iteration-count 1 62 | 63 | &[data-show-labels="true"] 64 | padding 4px 8px 2px 65 | 66 | > button 67 | width 66px 68 | 69 | .sgn__offer-shopping-list-counter 70 | margin-left 40px 71 | 72 | .sgn__nav-label 73 | display block 74 | 75 | .sgn-pp__progress 76 | height 16px 77 | position relative 78 | background-color rgba(0,0,0,0.1) 79 | animation none !important 80 | z-index 0 81 | 82 | .sgn-pp-progress__bar 83 | height 16px 84 | background-color rgba(0,0,0,0.2) 85 | 86 | .sgn-pp__progress-label 87 | display block 88 | font-family inherit 89 | bottom 2px 90 | font-size 11px 91 | margin-top 16px 92 | padding 0 93 | background transparent 94 | animation none !important 95 | color inherit 96 | 97 | .sgn-incito__scroll-progress 98 | width 100% 99 | height 16px 100 | background-color rgba(0,0,0, 0.1) 101 | position relative 102 | line-height 1.4 103 | 104 | .sgn-incito__scroll-progress-bar 105 | height 100% 106 | background-color rgba(0,0,0, 0.2) 107 | transition transform 0.2s ease 0s 108 | transform-origin 0px 0px 109 | transform scaleX(0) 110 | 111 | .sgn-incito__scroll-progress-text 112 | margin auto 113 | width 100% 114 | height 100% 115 | font-size 11px 116 | text-align center 117 | position absolute 118 | transform translateX(-50%) 119 | top 1px 120 | 121 | .sgn-animate-bounce 122 | animation-name sgn-animate-bounce 123 | animation-timing-function ease 124 | 125 | 126 | .sgn-animate-header 127 | display block 128 | animation-name sgn-animate-header 129 | animation-timing-function ease 130 | 131 | @keyframes sgn-animate-bounce 132 | 0% 133 | transform translateY(0) 134 | 50% 135 | transform translateY(-4px) 136 | 100% 137 | transform translateY(0) 138 | 139 | @keyframes sgn-animate-header 140 | 0% 141 | transform translateY(-100px) 142 | 100% 143 | transform translateY(0) -------------------------------------------------------------------------------- /lib/kits/core-ui/components/common/index.ts: -------------------------------------------------------------------------------- 1 | export {default as MenuPopup} from './menu-popup'; 2 | export {default as OfferList} from './offer-list'; 3 | export {default as PublicationDownload} from './publication-download'; 4 | export {default as ShoppingList} from './shopping-list'; 5 | export {default as OfferOverview} from './offer-overview'; 6 | -------------------------------------------------------------------------------- /lib/kits/core-ui/components/common/menu-popup.styl: -------------------------------------------------------------------------------- 1 | .sgn-menu-popup 2 | position relative 3 | background #ffffff 4 | color #202020 5 | border-radius 12px 6 | padding 10px 7 | width 90% 8 | max-width 666px 9 | min-height 200px 10 | margin auto 11 | margin-top 80px 12 | box-shadow rgba(0, 0, 0, 0.7) 0px 20px 55px, rgba(0, 0, 0, 0.3) 0px 0px 1px 13 | text-align left 14 | 15 | .sgn-menu-popup-labels 16 | padding 10px 0px 17 | line-height 1.5 18 | float left 19 | width 80% 20 | 21 | .sgn-menu-download 22 | text-align right 23 | float left 24 | width 20% 25 | padding 10px 0px 26 | 27 | > button 28 | background-color #d3d3d3 29 | padding 4px 10px 30 | font-size 12px 31 | border-radius 6px 32 | border 0 33 | transition .2s 34 | 35 | &:hover 36 | opacity 0.8 37 | cursor pointer 38 | 39 | .sgn-menu-label 40 | font-weight 600 41 | font-size 18px 42 | 43 | .sgn-menu-date 44 | font-weight 400 45 | font-size 16px 46 | vertical-align baseline 47 | border-radius 0.25em 48 | 49 | span 50 | &[data-validity-state="inactive"] 51 | color #f00000 52 | 53 | &[data-validity-state="expired"] 54 | color #f00000 55 | 56 | &[data-validity-state="active"] 57 | color #28af4c 58 | 59 | .sgn-menu-tab 60 | color #202020 61 | background-color rgba(139, 140, 143, 0.1) 62 | overflow hidden 63 | font-size 12px 64 | border-radius 8px 65 | align-items center 66 | 67 | 68 | .sgn-menu-tab-btn 69 | background-color #d3d3d3 70 | color #202020 71 | font-size 12px 72 | float left 73 | border none 74 | outline none 75 | cursor pointer 76 | padding 8px 3px 77 | transition .3s 78 | width 50% 79 | 80 | &:hover 81 | font-weight 600 82 | background-color transparent 83 | 84 | .sgn-menu-tab-btn-active 85 | font-weight 600 86 | background-color transparent 87 | 88 | .sgn-menu-tab-content 89 | display none 90 | 91 | a 92 | text-decoration none 93 | color #202020 94 | 95 | .sgn-menu-tab-content-active 96 | display block 97 | 98 | .sgn__theme-dark 99 | .sgn-menu-popup 100 | background rgb(28, 28, 30) 101 | color #F3F3F9 102 | box-shadow rgba(0, 0, 0, 0.7) 0px 20px 55px, rgba(0, 0, 0, 0.3) 0px 0px 1px 103 | 104 | .sgn-menu-download 105 | > button 106 | color #F3F3F9 107 | background-color #2c2c2e 108 | 109 | .sgn-menu-tab 110 | color #F3F3F9 111 | background-color rgba(139, 140, 143, 0.1) 112 | 113 | .sgn-menu-tab-btn 114 | background-color rgb(44, 44, 46) 115 | color #F3F3F9 116 | 117 | &:hover 118 | background-color transparent 119 | 120 | .sgn-menu-tab-btn-active 121 | background-color transparent 122 | 123 | .sgn-menu-tab-content 124 | a 125 | color #F3F3F9 126 | 127 | .sgn-modal-container 128 | position fixed 129 | top 0 130 | left 0 131 | bottom 0 132 | right 0 133 | content ' ' 134 | background rgba(0, 0, 0, 0.1) 135 | z-index 99 136 | animation fadeIn .3s 137 | 138 | .sgn-modal-container-on-destroy 139 | opacity 0 140 | animation fadeOut .3s 141 | 142 | .sgn-blocker 143 | position fixed 144 | top 0 145 | left 0 146 | bottom 0 147 | right 0 148 | content ' ' 149 | 150 | @keyframes fadeIn 151 | 0% 152 | opacity 0 153 | 100% 154 | opacity 1 155 | 156 | @keyframes fadeOut 157 | 0% 158 | opacity 1 159 | 100% 160 | opacity 0 161 | -------------------------------------------------------------------------------- /lib/kits/core-ui/components/common/offer-list.styl: -------------------------------------------------------------------------------- 1 | .sgn-offers-list-items-container 2 | height 650px 3 | overflow-y scroll 4 | list-style-type none 5 | padding 0 10px 6 | color #000000 7 | 8 | .sgn-offers-search-container 9 | padding-top 10px 10 | 11 | .sgn-offers-search-text 12 | border-radius 4px 13 | background-color #f1f1f1 14 | color #202020 15 | border 1px solid rgba(255, 255, 255, 0.15) 16 | width 100% 17 | box-sizing border-box 18 | padding 8px 19 | 20 | .sgn-offers-list-item-container 21 | width 31% 22 | text-align center 23 | margin 1% 24 | margin-bottom 12px 25 | float left 26 | 27 | .sgn-offers-list-item-container:nth-child(3n+1) 28 | clear left 29 | 30 | .sgn-offers-content-container 31 | text-decoration none 32 | padding 12px 33 | box-shadow rgba(0, 0, 0, .3) 0px 0px 10px 34 | border-radius 10px 35 | 36 | &:hover 37 | cursor pointer 38 | 39 | .sgn-offers-content-img 40 | background-color #f1f1f1 41 | background-size cover 42 | background-position center center 43 | border-radius 6px 44 | overflow hidden 45 | position relative 46 | border 0px 47 | width 100% 48 | height auto 49 | padding-top 100% 50 | 51 | > img 52 | object-fit contain 53 | object-position center center 54 | width 100% 55 | height 100% 56 | position absolute 57 | top 0 58 | left 0 59 | right 0 60 | 61 | .sgn-offers-content-text 62 | white-space nowrap 63 | overflow hidden 64 | color #202020 65 | padding 4px 0px 0px 0px 66 | text-align left 67 | 68 | .sgn-offers-content-heading 69 | font-weight 600 70 | line-height 1.2 71 | white-space nowrap 72 | overflow hidden 73 | text-overflow ellipsis 74 | max-width 300px 75 | 76 | .sgn-offers-content-description 77 | font-size 0.9em 78 | opacity 0.6 79 | color inherit 80 | white-space nowrap 81 | overflow hidden 82 | text-overflow ellipsis 83 | 84 | .sgn-offers-content-price 85 | font-variant-numeric tabular-nums 86 | color rgb(40, 175, 76) 87 | font-size 0.9em 88 | 89 | .sgn__theme-dark 90 | .sgn-offers-search-text 91 | background-color rgb(44, 44, 46) 92 | color rgb(243, 243, 249) 93 | border 2px solid rgba(0, 0, 0, 0.15) 94 | 95 | .sgn-offers-content-container 96 | box-shadow rgba(243, 243, 249, .15) 0px 0px 10px 97 | 98 | .sgn-offers-content-img 99 | background-color rgb(204, 204, 204) 100 | 101 | .sgn-offers-content-text 102 | color #F3F3F9 103 | 104 | @media (max-width 450px) 105 | .sgn-offers-list-item-container 106 | width 48% 107 | 108 | .sgn-offers-list-item-container:nth-child(3n+1) 109 | clear none 110 | 111 | .sgn-offers-list-item-container:nth-child(2n+1) 112 | clear left 113 | 114 | @media (max-height 900px) 115 | .sgn-offers-list-items-container 116 | height 500px 117 | 118 | @media (max-height 790px) 119 | .sgn-offers-list-items-container 120 | height 360px 121 | -------------------------------------------------------------------------------- /lib/kits/core-ui/components/common/publication-download.ts: -------------------------------------------------------------------------------- 1 | import {request} from '../../../core'; 2 | 3 | const PublicationDownload = ({configs}) => ({ 4 | render: () => { 5 | document.querySelector('.sgn__offer-download')?.addEventListener( 6 | 'click', 7 | async () => { 8 | const pdf_url = ( 9 | await request<{pdf_url: string}>({ 10 | apiKey: configs.apiKey, 11 | coreUrl: configs.coreUrl, 12 | url: `/v2/catalogs/${configs?.id}/download` 13 | }) 14 | )?.pdf_url; 15 | 16 | if (pdf_url) location.href = pdf_url; 17 | }, 18 | false 19 | ); 20 | } 21 | }); 22 | 23 | export default PublicationDownload; 24 | -------------------------------------------------------------------------------- /lib/kits/core-ui/components/helpers/transformers.ts: -------------------------------------------------------------------------------- 1 | export const transformScriptData = ( 2 | scriptEl: HTMLScriptElement, 3 | mainContainer: string 4 | ) => { 5 | const {dataset} = scriptEl; 6 | 7 | return { 8 | businessId: dataset.businessId, 9 | mainContainer: dataset.componentPublicationContainer || mainContainer, 10 | listPublicationsContainer: 11 | dataset.componentListPublicationsContainer || mainContainer, 12 | publicationId: dataset.componentPublicationId, 13 | publicationIdParam: dataset.publicationIdQueryParam || 'publicationid', 14 | sectionIdParam: dataset.publicationSectionQueryParam || 'sectionid', 15 | pageIdParam: dataset.publicationPageQueryParam || 'publicationpage', 16 | publicationHash: dataset.publicationHash || 'publication', 17 | displayUrlParams: dataset.componentPublicationDisplayUrlParams, 18 | localeCode: dataset.localeCode, 19 | translationKeys: dataset.translationKeyIncito_publication_viewer, 20 | theme: dataset.componentTheme, 21 | publicationsListClickBehavior: 22 | dataset.componentPublicationsListItemClickBehavior || 23 | 'open_publication_viewer', 24 | offerClickBehavior: 25 | dataset.componentPublicationViewerOfferClickBehavior || 26 | 'shopping_list', 27 | disableShoppingList: 28 | dataset.componentPublicationDisableShoppingList === 'true', 29 | disableClose: dataset.componentPublicationDisableClose === 'true', 30 | disableMenu: dataset.componentPublicationDisableMenu === 'true', 31 | disableDownload: dataset.componentPublicationDisableDownload === 'true', 32 | disableHeader: 33 | dataset.componentPublicationDisableHeader === 'true' || 34 | (dataset.componentPublicationDisableShoppingList === 'true' && 35 | dataset.componentPublicationDisableMenu === 'true' && 36 | dataset.componentPublicationDisableClose === 'true'), 37 | disableGlobalScrollbar: 38 | dataset.componentPublicationDisableGlobalScrollbar === 'true', 39 | disablePageDecorations: 40 | dataset.componentPublicationDisablePagedecorations === 'true', 41 | showHeaderLabels: 42 | dataset.componentPublicationShowHeaderLabels === 'true', 43 | enableSidebar: dataset.componentPublicationEnableSidebar === 'true', 44 | sidebarPosition: 45 | dataset.componentPublicationSidebarPosition === 'left' 46 | ? 'left' 47 | : 'right', 48 | showQuantityButtons: 49 | dataset.componentPublicationShowQuantityButtons === 'true', 50 | enableLazyload: dataset.componentPublicationEnableLazyload === 'true', 51 | requestFilter: dataset.componentListPublicationsRequestFilter, 52 | clientFilter: dataset.componentListPublicationsClientFilter, 53 | noOfferLinkMessage: dataset.componentPublicationNoOfferLinkMessage, 54 | disableUtm: dataset.componentPublicationDisableUtm === 'true', 55 | utmSource: dataset.componentPublicationUtmSource, 56 | utmMedium: dataset.componentPublicationUtmMedium 57 | }; 58 | }; 59 | -------------------------------------------------------------------------------- /lib/kits/core-ui/components/incito-publication/index.ts: -------------------------------------------------------------------------------- 1 | export {default as MainContainer} from './main-container'; 2 | export {default as SectionList} from './section-list'; 3 | -------------------------------------------------------------------------------- /lib/kits/core-ui/components/incito-publication/main-container.styl: -------------------------------------------------------------------------------- 1 | .sgn-incito-publication--open 2 | overflow hidden !important 3 | 4 | .sgn_loader-container 5 | position fixed 6 | background #fff 7 | top 0 8 | left 0 9 | right 0 10 | bottom 0 11 | 12 | .sgn_loader 13 | position absolute 14 | top calc(50% - 80px) 15 | left calc(50% - 50px) 16 | border: 16px solid #2c2c2e 17 | border-top 16px solid #8b8c8f 18 | border-radius 50% 19 | width 80px 20 | height 80px 21 | animation spin 2s linear infinite 22 | 23 | @keyframes spin 24 | 0% 25 | transform: rotate(0deg) 26 | 100% 27 | transform: rotate(360deg) 28 | 29 | .sgn__incito 30 | &[data-component-template="true"] 31 | font-family inherit 32 | position fixed 33 | top 0 34 | left 0 35 | right 0 36 | bottom 0 37 | overflow-y auto 38 | z-index 99 39 | 40 | button 41 | font-family inherit 42 | 43 | input 44 | font-family inherit 45 | 46 | .incito 47 | padding-top 80px 48 | 49 | .sgn__nav 50 | position fixed 51 | transform translateX(-50%) 52 | z-index 1 53 | white-space nowrap 54 | 55 | &[data-offer-clickable="true"] 56 | .incito__view 57 | &[data-role="offer"] 58 | cursor pointer 59 | 60 | .sgn-offer-link-overlay 61 | position absolute 62 | display flex 63 | align-items center 64 | justify-content center 65 | text-align center 66 | top 0 67 | left 0 68 | width 100% 69 | height 100% 70 | background rgba(0, 0, 0, 0.5) 71 | color #ffffff 72 | padding 10px 73 | animation-name sgn-animate-link-overlay 74 | animation-timing-function ease 75 | animation-duration 1.6s 76 | 77 | &[data-component-template-disable-header="true"] 78 | &[data-component-template-enable-sidebar="true"] 79 | .incito 80 | padding-top 0 81 | 82 | .sgn-incito__scroll-progress 83 | position -webkit-sticky 84 | position sticky 85 | top calc(100% - 4px) 86 | margin-bottom -4px 87 | width 100% 88 | height 4px 89 | z-index 99 90 | text-align center 91 | color #ffffff 92 | 93 | .sgn-incito__scroll-progress-text 94 | display block 95 | top -34px 96 | background rgba(0,0,0,0.3) 97 | left 50% 98 | width auto 99 | height auto 100 | padding 6px 18px 101 | font-size 14px 102 | border-radius 6px 103 | font-weight bold 104 | letter-spacing -1px 105 | line-height 1 106 | 107 | 108 | @keyframes sgn-animate-link-overlay 109 | 0% 110 | opacity 0 111 | 15% 112 | opacity 1 113 | 85% 114 | opacity 1 115 | 100% 116 | opacity 0 -------------------------------------------------------------------------------- /lib/kits/core-ui/components/incito-publication/main-container.ts: -------------------------------------------------------------------------------- 1 | import Mustache from 'mustache'; 2 | import {transformScriptData} from '../helpers/transformers'; 3 | import './main-container.styl'; 4 | 5 | const defaultTemplate = `\ 6 |
7 |
8 |
9 |
10 |
11 | {{#enableSidebar}} 12 |
13 | {{/enableSidebar}} 14 | {{#displayBottomScrollProgress}} 15 |
16 |
17 | 18 |
19 | {{/displayBottomScrollProgress}} 20 |
\ 21 | `; 22 | 23 | const MainContainer = ({ 24 | template, 25 | el, 26 | scriptEls 27 | }: { 28 | template?: Element | null; 29 | el: Element | null; 30 | scriptEls: ReturnType; 31 | }) => { 32 | const setCustomStyles = () => { 33 | const sgnIncito = el?.querySelector('.sgn__incito'); 34 | sgnIncito?.classList.add(`sgn__theme-${scriptEls.theme || 'light'}`); 35 | }; 36 | 37 | const render = () => { 38 | if (!el) return; 39 | 40 | el.innerHTML = Mustache.render(template?.innerHTML || defaultTemplate, { 41 | disableHeader: scriptEls.disableHeader, 42 | disableShoppingList: 43 | scriptEls.disableShoppingList || 44 | scriptEls.offerClickBehavior !== 'shopping_list', 45 | disableMenu: scriptEls.disableMenu, 46 | disableClose: scriptEls.disableClose, 47 | enableSidebar: scriptEls.enableSidebar, 48 | sidebarPosition: scriptEls.sidebarPosition, 49 | displayBottomScrollProgress: 50 | scriptEls.disableHeader || scriptEls.enableSidebar, 51 | isOfferClickable: 52 | (!scriptEls.disableShoppingList || 53 | scriptEls.offerClickBehavior !== 'shopping_list') && 54 | (!scriptEls.disableHeader || 55 | scriptEls.offerClickBehavior !== 'shopping_list') 56 | }); 57 | 58 | el.querySelector('.sgn__incito')?.focus(); 59 | 60 | setCustomStyles(); 61 | }; 62 | 63 | return {render}; 64 | }; 65 | 66 | export default MainContainer; 67 | -------------------------------------------------------------------------------- /lib/kits/core-ui/components/incito-publication/section-list.styl: -------------------------------------------------------------------------------- 1 | .sgn-sections-list-items-container 2 | height 650px 3 | overflow-y scroll 4 | list-style-type none 5 | padding 0 10px 6 | 7 | .sgn-sections-list-item-container 8 | 9 | &:hover 10 | cursor pointer 11 | background-color rgba(0, 0, 0, 0.2) 12 | 13 | .sgn-sections-content-container 14 | border-bottom 1px solid #d5d5d5 15 | padding 10px 16 | 17 | .sgn-sections-list-item-active 18 | background-color rgba(0, 0, 0, 0.2) 19 | 20 | @media (max-height 900px) 21 | .sgn-sections-list-items-container 22 | height 540px 23 | 24 | @media (max-height 790px) 25 | .sgn-sections-list-items-container 26 | height 400px 27 | -------------------------------------------------------------------------------- /lib/kits/core-ui/components/incito-publication/section-list.ts: -------------------------------------------------------------------------------- 1 | import Mustache from 'mustache'; 2 | import {destroyModal, closeSidebar} from '../helpers/component'; 3 | import './section-list.styl'; 4 | 5 | const defaultTemplate = `\ 6 |
7 |
    8 | {{#sections}} 9 |
  1. 10 |
    11 |
    12 |
    13 | {{title}} 14 |
    15 |
    16 |
    17 |
  2. 18 | {{/sections}} 19 |
20 |
\ 21 | `; 22 | 23 | const SectionList = ({sgnData, template, scriptEls}) => { 24 | let container: HTMLDivElement | null = null; 25 | 26 | template = template?.innerHTML || defaultTemplate; 27 | 28 | const render = async () => { 29 | container = document.createElement('div'); 30 | container.className = 'sgn-sections-container'; 31 | container.innerHTML = Mustache.render(template, { 32 | sections: sgnData?.incito?.table_of_contents 33 | }); 34 | 35 | addSectionClickListener(); 36 | addSectionScrollListener(); 37 | 38 | return container; 39 | }; 40 | 41 | const addSectionScrollListener = () => { 42 | const mainContainerEl = document.querySelector( 43 | scriptEls.listPublicationsContainer || scriptEls.mainContainer 44 | ); 45 | 46 | mainContainerEl.addEventListener('section:show', (e) => { 47 | const {view_id} = e.detail; 48 | 49 | const listItem = container?.querySelector( 50 | `.sgn-sections-list-item-container[data-section-id="${view_id}"]` 51 | ); 52 | 53 | container 54 | ?.querySelectorAll('.sgn-sections-list-item-container') 55 | ?.forEach((itemEl) => { 56 | itemEl.classList.remove('sgn-sections-list-item-active'); 57 | }); 58 | 59 | listItem?.classList.add('sgn-sections-list-item-active'); 60 | }); 61 | }; 62 | 63 | const addSectionClickListener = () => { 64 | container 65 | ?.querySelectorAll('.sgn-sections-list-item-container') 66 | .forEach((itemEl) => { 67 | itemEl.addEventListener('click', scrollToSection, false); 68 | }); 69 | }; 70 | 71 | const scrollToSection = (e) => { 72 | const sectionId = e.currentTarget.dataset?.sectionId; 73 | const sectionCell = document.querySelector( 74 | `[data-id="${sectionId}"][data-role="section"]` 75 | ); 76 | const incitoEl = scriptEls.enableSidebar 77 | ? document.querySelector('.incito') 78 | : document.querySelector('.sgn__incito'); 79 | const headerOffset = document.querySelector('.sgn__header') ? 76 : 0; 80 | // @ts-expect-error 81 | const sectionOffset = sectionCell.offsetTop - headerOffset || 0; 82 | 83 | destroyModal(); 84 | closeSidebar(); 85 | 86 | incitoEl?.scrollTo({top: sectionOffset, behavior: 'smooth'}); 87 | }; 88 | 89 | return {render}; 90 | }; 91 | 92 | export default SectionList; 93 | -------------------------------------------------------------------------------- /lib/kits/core-ui/components/list-publications/index.ts: -------------------------------------------------------------------------------- 1 | export {default as MainContainer} from './main-container'; 2 | -------------------------------------------------------------------------------- /lib/kits/core-ui/components/list-publications/main-container.styl: -------------------------------------------------------------------------------- 1 | .sgn__publications 2 | font-family inherit 3 | font-size 14px 4 | color #202020 5 | 6 | .sgn-publications-list-items-container 7 | display grid 8 | grid-template-columns repeat(auto-fill, minmax(200px, 1fr)) 9 | gap 24px 36px 10 | margin-top 0px 11 | margin-bottom 0px 12 | padding 0px 13 | list-style none 14 | 15 | .sgn-publications-li 16 | position relative 17 | 18 | &[data-status="inactive"] 19 | .sgn-publications-list-content-img 20 | > img 21 | opacity 0.5 22 | 23 | .publications__item 24 | box-shadow rgba(0, 0, 0, .4) 0px 0px 10px 25 | border-radius 10px 26 | padding 12px 27 | transition transform ease 200ms 28 | 29 | &:hover 30 | cursor pointer 31 | transform scale(1.02) 32 | 33 | .sgn-publications-list-content-img 34 | width 100% 35 | padding-top 132% 36 | position relative 37 | border-radius 10px 38 | background rgba(151, 149, 146, 0.5) 39 | overflow hidden 40 | margin-bottom 10px 41 | 42 | > img 43 | object-fit cover 44 | object-position center top 45 | width 100% 46 | height 100% 47 | position absolute 48 | top 0 49 | 50 | .sgn-publications-list-content-heading 51 | font-weight 600 52 | text-overflow ellipsis 53 | white-space nowrap 54 | overflow hidden 55 | 56 | .sgn-publications-list-content-status 57 | position absolute 58 | bottom 0 59 | left 0 60 | width 100% 61 | background-color rgba(0, 0, 0, 0.6) 62 | color #ffffff 63 | font-weight 600 64 | font-size 20px 65 | line-height 28px 66 | text-align center 67 | border-radius 10px 68 | 69 | .sgn-publications-list-content-date 70 | font-size 12px 71 | 72 | @media (max-width 451px) 73 | .sgn-publications-list-items-container 74 | display grid 75 | grid-template-columns repeat(auto-fill, minmax(130px, 1fr)) 76 | 77 | @media (max-width 311px) 78 | .sgn-publications-list-items-container 79 | display grid 80 | grid-template-columns repeat(auto-fill, minmax(80px, 1fr)) -------------------------------------------------------------------------------- /lib/kits/core-ui/components/list-publications/main-container.ts: -------------------------------------------------------------------------------- 1 | import Mustache from 'mustache'; 2 | import './main-container.styl'; 3 | import {translate} from '../helpers/component'; 4 | 5 | const defaultTemplate = `\ 6 |
7 |
    8 | {{#publications}} 9 |
  • 10 |
    11 |
    12 | {{label}} 13 |
    14 | {{upcomingLabel}} 15 |
    16 |
    17 |
    18 |
    19 | {{label}} 20 |
    21 | 24 |
    25 |
    26 |
  • 27 | {{/publications}} 28 | 29 |
30 |
31 | \ 32 | `; 33 | 34 | const MainContainer = ({publications, template, el}) => { 35 | const translations = { 36 | upcoming: translate('publication_viewer_upcoming'), 37 | validFrom: translate('publication_viewer_offer_valid_from'), 38 | till: translate('publication_viewer_until_label') 39 | }; 40 | 41 | const render = () => { 42 | el.innerHTML = Mustache.render(template?.innerHTML || defaultTemplate, { 43 | publications, 44 | translations, 45 | upcomingLabel: function () { 46 | return this.status === 'inactive' ? translations.upcoming : ''; 47 | } 48 | }); 49 | }; 50 | 51 | return {render}; 52 | }; 53 | 54 | export default MainContainer; 55 | -------------------------------------------------------------------------------- /lib/kits/core-ui/components/paged-publication/index.ts: -------------------------------------------------------------------------------- 1 | export {default as MainContainer} from './main-container'; 2 | export {default as PageList} from './page-list'; 3 | -------------------------------------------------------------------------------- /lib/kits/core-ui/components/paged-publication/main-container.styl: -------------------------------------------------------------------------------- 1 | .sgn-paged-publication--open 2 | overflow hidden !important 3 | 4 | .sgn__pp 5 | &[data-component-template="true"] 6 | font-family inherit 7 | 8 | button 9 | font-family inherit 10 | 11 | input 12 | font-family inherit 13 | 14 | .verso__scroller 15 | top 80px 16 | 17 | .sgn-pp__control 18 | background transparent 19 | border 0 20 | 21 | &[data-layout-fixed="true"] 22 | z-index 99 23 | 24 | &[data-component-template-disable-header="true"] 25 | &[data-component-template-enable-sidebar="true"] 26 | .verso__scroller 27 | top 0 28 | 29 | &[data-zoomed-in="true"] 30 | .sgn__nav-content 31 | padding 8px 14px 32 | 33 | .sgn-pp__control 34 | cursor pointer 35 | transition opacity ease .3s 36 | font-size 60px 37 | 38 | &[data-direction="prev"] 39 | left 50px 40 | 41 | &[data-direction="next"] 42 | right 50px 43 | 44 | &[data-direction="first"] 45 | left 8px 46 | 47 | &[data-direction="last"] 48 | right 8px 49 | 50 | .sgn-clearfix 51 | clear both 52 | -------------------------------------------------------------------------------- /lib/kits/core-ui/components/paged-publication/main-container.ts: -------------------------------------------------------------------------------- 1 | import Mustache from 'mustache'; 2 | import {transformScriptData} from '../helpers/transformers'; 3 | import './main-container.styl'; 4 | 5 | const defaultTemplate = `\ 6 |
7 | 8 |
9 |
10 |
11 |
12 |
13 | 14 |
15 | 16 | 23 | 30 | 37 | 44 |
45 | 46 | {{#enableSidebar}} 47 |
48 | {{/enableSidebar}} 49 | 50 | {{#disableHeader}} 51 |
52 |
53 |
54 |
55 | {{/disableHeader}} 56 |
\ 57 | `; 58 | 59 | const MainContainer = ({ 60 | template, 61 | el, 62 | scriptEls 63 | }: { 64 | template: Element | null; 65 | el: Element | null; 66 | scriptEls: ReturnType; 67 | }) => { 68 | const setCustomStyles = () => { 69 | // @ts-expect-error 70 | const sgnPp = el.querySelector('.sgn__pp'); 71 | sgnPp?.classList.add(`sgn__theme-${scriptEls.theme || 'light'}`); 72 | }; 73 | 74 | const render = () => { 75 | // @ts-expect-error 76 | el.innerHTML = Mustache.render(template?.innerHTML || defaultTemplate, { 77 | disableHeader: scriptEls.disableHeader, 78 | enableSidebar: scriptEls.enableSidebar, 79 | sidebarPosition: scriptEls.sidebarPosition 80 | }); 81 | 82 | setCustomStyles(); 83 | }; 84 | 85 | return {render}; 86 | }; 87 | 88 | export default MainContainer; 89 | -------------------------------------------------------------------------------- /lib/kits/core-ui/components/paged-publication/page-decoration-list.styl: -------------------------------------------------------------------------------- 1 | .sgn-page-decoration-list-items-container 2 | overflow-y scroll 3 | list-style-type none 4 | padding 0 10px 5 | 6 | .sgn-page-decoration-list-item-container 7 | 8 | &:hover 9 | cursor pointer 10 | 11 | .sgn-page-decoration-content-container 12 | border-bottom 1px solid #d5d5d5 13 | padding 10px 14 | -------------------------------------------------------------------------------- /lib/kits/core-ui/components/paged-publication/page-decoration-list.ts: -------------------------------------------------------------------------------- 1 | import Mustache from 'mustache'; 2 | import {V2PageDecoration} from '../../../core'; 3 | import {Viewer} from '../../../paged-publication'; 4 | import {destroyModal, pushQueryParam} from '../helpers/component'; 5 | import {transformScriptData} from '../helpers/transformers'; 6 | import './page-decoration-list.styl'; 7 | 8 | const defaultTemplate = `\ 9 |
10 |
    11 | {{#pageDecorations}} 12 |
  1. 13 | 20 |
  2. 21 | {{/pageDecorations}} 22 |
23 |
\ 24 | `; 25 | 26 | const PageDecorationList = ({ 27 | scriptEls, 28 | configs, 29 | sgnPageDecorations, 30 | sgnViewer, 31 | template 32 | }: { 33 | scriptEls: ReturnType; 34 | configs: {apiKey: string; coreUrl: string; id?: string}; 35 | sgnPageDecorations?: V2PageDecoration[]; 36 | sgnViewer?: Viewer; 37 | template?: HTMLElement | null; 38 | }) => { 39 | let container: HTMLDivElement | null = null; 40 | 41 | const render = async () => { 42 | container = document.createElement('div'); 43 | container.className = 'sgn-pages-container'; 44 | 45 | container.innerHTML = Mustache.render( 46 | template?.innerHTML || defaultTemplate, 47 | { 48 | pageDecorations: sgnPageDecorations 49 | } 50 | ); 51 | 52 | addPageClickListener(); 53 | 54 | return container; 55 | }; 56 | 57 | const addPageClickListener = () => { 58 | container?.querySelectorAll('.sgn-page-item').forEach((itemEl) => { 59 | itemEl.addEventListener('click', navigateToPage, false); 60 | }); 61 | }; 62 | 63 | const navigateToPage = (e) => { 64 | e.preventDefault(); 65 | const {pageId, pageNum} = e.currentTarget.dataset; 66 | 67 | destroyModal(); 68 | sgnViewer?.navigateToPageId(pageId); 69 | 70 | if (scriptEls.displayUrlParams?.toLowerCase() === 'query') { 71 | pushQueryParam({ 72 | [scriptEls.publicationIdParam]: configs.id, 73 | [scriptEls.pageIdParam]: pageNum 74 | }); 75 | } else if (scriptEls.displayUrlParams?.toLowerCase() === 'hash') { 76 | location.hash = `${scriptEls.publicationHash}/${configs.id}/${pageNum}`; 77 | } 78 | }; 79 | 80 | return {render}; 81 | }; 82 | 83 | export default PageDecorationList; 84 | -------------------------------------------------------------------------------- /lib/kits/core-ui/components/paged-publication/page-list.styl: -------------------------------------------------------------------------------- 1 | .sgn-pages-list-items-container 2 | height 650px 3 | overflow-y scroll 4 | list-style-type none 5 | padding 0 10px 6 | 7 | .sgn-pages-list-item-container 8 | width 31% 9 | text-align center 10 | margin 1% 11 | padding-bottom 20px 12 | float left 13 | 14 | .sgn-pages-img-container 15 | padding 8px 16 | 17 | > img 18 | width 100% 19 | border-radius 8px 20 | 21 | .sgn-page-item 22 | text-decoration none 23 | color inherit 24 | 25 | @media (max-width: 450px) 26 | .sgn-pages-list-item-container 27 | width 48% 28 | 29 | @media (max-height 900px) 30 | .sgn-pages-list-items-container 31 | height 540px 32 | 33 | @media (max-height 790px) 34 | .sgn-pages-list-items-container 35 | height 400px 36 | -------------------------------------------------------------------------------- /lib/kits/core-ui/components/paged-publication/page-list.ts: -------------------------------------------------------------------------------- 1 | import Mustache from 'mustache'; 2 | import {request, V2Page} from '../../../core'; 3 | import {Viewer} from '../../../paged-publication'; 4 | import {destroyModal, pushQueryParam, closeSidebar} from '../helpers/component'; 5 | import {transformScriptData} from '../helpers/transformers'; 6 | import './page-list.styl'; 7 | 8 | const defaultTemplate = `\ 9 |
10 |
    11 | {{#pages}} 12 |
  1. 13 | 25 |
  2. 26 | {{/pages}} 27 |
28 |
\ 29 | `; 30 | 31 | const PageList = ({ 32 | scriptEls, 33 | configs, 34 | sgnData, 35 | sgnViewer, 36 | template 37 | }: { 38 | scriptEls: ReturnType; 39 | configs: {apiKey: string; coreUrl: string; id?: string}; 40 | sgnData?: {pages?: V2Page[]}; 41 | sgnViewer?: Viewer; 42 | template?: HTMLElement | null; 43 | }) => { 44 | let container: HTMLDivElement | null = null; 45 | 46 | const render = async () => { 47 | container = document.createElement('div'); 48 | container.className = 'sgn-pages-container'; 49 | container.innerHTML = Mustache.render( 50 | template?.innerHTML || defaultTemplate, 51 | { 52 | pages: transformPages( 53 | sgnData?.pages?.length 54 | ? sgnData.pages 55 | : await request({ 56 | apiKey: configs.apiKey, 57 | coreUrl: configs.coreUrl, 58 | url: `/v2/catalogs/${configs.id}/pages` 59 | }) 60 | ) 61 | } 62 | ); 63 | 64 | addPageClickListener(); 65 | 66 | return container; 67 | }; 68 | 69 | const addPageClickListener = () => { 70 | container?.querySelectorAll('.sgn-page-item').forEach((itemEl) => { 71 | itemEl.addEventListener('click', navigateToPage, false); 72 | }); 73 | }; 74 | 75 | const navigateToPage = (e) => { 76 | e.preventDefault(); 77 | const {pageId, pageNum} = e.currentTarget.dataset; 78 | 79 | destroyModal(); 80 | closeSidebar(); 81 | sgnViewer?.navigateToPageId(pageId); 82 | 83 | if (scriptEls.displayUrlParams?.toLowerCase() === 'query') { 84 | pushQueryParam({ 85 | [scriptEls.publicationIdParam]: configs.id, 86 | [scriptEls.pageIdParam]: pageNum 87 | }); 88 | } else if (scriptEls.displayUrlParams?.toLowerCase() === 'hash') { 89 | location.hash = `${scriptEls.publicationHash}/${configs.id}/${pageNum}`; 90 | } 91 | }; 92 | 93 | const transformPages = (pages) => 94 | pages.map((page, index) => ({ 95 | ...page, 96 | pageId: `page${index + 1}`, 97 | pageNum: index + 1, 98 | index 99 | })); 100 | 101 | return {render}; 102 | }; 103 | 104 | export default PageList; 105 | -------------------------------------------------------------------------------- /lib/kits/core-ui/index.ts: -------------------------------------------------------------------------------- 1 | export {off, on} from '../../util'; 2 | export {default as IncitoPublication} from './incito-publication'; 3 | export {default as ListPublications} from './list-publications'; 4 | export {default as OfferDetails} from './offer-details'; 5 | export {default as PagedPublication} from './paged-publication'; 6 | export {default as Popover} from './popover'; 7 | export {default as singleChoicePopover} from './single-choice-popover'; 8 | -------------------------------------------------------------------------------- /lib/kits/core-ui/offer-details.styl: -------------------------------------------------------------------------------- 1 | .sgn-offer-details 2 | absolute top 3 | z-index 10 4 | background-color #fff 5 | border-left 4px solid rgba(#000, 0.8) 6 | border-right 4px solid rgba(#000, 0.8) 7 | border-bottom 4px solid rgba(#000, 0.8) 8 | border-top 4px solid rgba(#000, 0.8) 9 | border-radius bottom left 10px 10 | border-radius bottom right 10px 11 | padding 10px 12 | opacity 0 13 | transition all ease 0.2s 14 | transform translateY(-50%) scale(0.8) 15 | outline 0 16 | box-sizing border-box 17 | 18 | & > &-inner 19 | background #fff 20 | height 4px 21 | top -4px 22 | position absolute 23 | 24 | &.in 25 | opacity 1 26 | transform translateY(0px) scale(1) -------------------------------------------------------------------------------- /lib/kits/core-ui/offer-details.ts: -------------------------------------------------------------------------------- 1 | import './offer-details.styl'; 2 | 3 | export default class OfferDetails { 4 | el: HTMLDivElement; 5 | elInner: HTMLDivElement; 6 | contentEl: HTMLDivElement; 7 | anchorEl: HTMLDivElement; 8 | minWidth: number | string; 9 | maxWidth: number | string; 10 | constructor({minWidth = 300, maxWidth = '100vw', anchorEl, contentEl}) { 11 | this.minWidth = minWidth; 12 | this.maxWidth = maxWidth; 13 | this.anchorEl = anchorEl; 14 | this.contentEl = contentEl; 15 | this.elInner = document.createElement('div'); 16 | this.elInner.className = 'sgn-offer-details-inner'; 17 | 18 | this.el = document.createElement('div'); 19 | 20 | this.el.className = 'sgn-offer-details'; 21 | this.el.setAttribute('tabindex', '-1'); 22 | this.el.appendChild(this.elInner); 23 | this.el.appendChild(this.contentEl); 24 | 25 | this.position(); 26 | } 27 | 28 | appendTo(el: Element) { 29 | el.appendChild(this.el); 30 | 31 | this.show(); 32 | 33 | return this; 34 | } 35 | 36 | show() { 37 | this.el.className += ' in'; 38 | 39 | window.addEventListener('resize', this.resize, false); 40 | 41 | return this; 42 | } 43 | 44 | destroy() { 45 | window.removeEventListener('resize', this.resize, false); 46 | 47 | this.el.parentNode?.removeChild(this.el); 48 | } 49 | 50 | position() { 51 | const rect = this.anchorEl.getBoundingClientRect(); 52 | const top = window.pageYOffset + rect.top + this.anchorEl.offsetHeight; 53 | let left = window.pageXOffset + rect.left; 54 | const width = this.anchorEl.offsetWidth; 55 | 56 | this.el.style.top = top + 'px'; 57 | 58 | const rightAligned = rect.left >= window.outerWidth / 2; 59 | left = window.pageXOffset + rect.left; 60 | const right = window.pageXOffset + (window.outerWidth - rect.right); 61 | 62 | if (rightAligned) { 63 | this.el.style.left = 'auto'; 64 | this.el.style.right = right + 'px'; 65 | 66 | this.elInner.style.left = 'auto'; 67 | this.elInner.style.right = '0'; 68 | } else { 69 | this.el.style.left = left + 'px'; 70 | this.el.style.right = 'auto'; 71 | 72 | this.elInner.style.left = '0'; 73 | this.elInner.style.right = 'auto'; 74 | } 75 | 76 | this.el.style.minWidth = 77 | typeof this.minWidth === 'number' 78 | ? Math.max(width, this.minWidth) + 'px' 79 | : this.minWidth; 80 | this.el.style.maxWidth = String(this.maxWidth); 81 | 82 | this.elInner.style.width = width - 8 + 'px'; 83 | } 84 | 85 | resize = () => { 86 | this.position(); 87 | }; 88 | } 89 | -------------------------------------------------------------------------------- /lib/kits/core-ui/page-decorations.styl: -------------------------------------------------------------------------------- 1 | .sgn__pp 2 | .sgn-pagedecoration 3 | position absolute 4 | outline 0 5 | background-color #fff 6 | color #3e3e3e 7 | border-radius 12px 8 | padding 8px 16px 9 | box-shadow rgba(0, 0, 0, 0.4) 0px 5px 16px, rgba(0, 0, 0, 0.2) 0px 0px 1px 10 | bottom 26px 11 | left 50% 12 | transform translateX(-50%) 13 | animation-name sgn-animate-pagedecoration 14 | animation-timing-function ease 15 | animation-duration .5s 16 | z-index 99 17 | 18 | a 19 | text-decoration none 20 | color inherit 21 | 22 | .sgn-pagedecoration-item__domain 23 | font-size 14px 24 | margin 0 25 | text-align center 26 | 27 | .sgn-pagedecoration-hidden 28 | display none 29 | 30 | @keyframes sgn-animate-pagedecoration 31 | 0% 32 | opacity 0 33 | 10% 34 | opacity 0 35 | 100% 36 | opacity 1 37 | 38 | .sgn__pp 39 | &[data-component-template-disable-header="true"] 40 | .sgn-pagedecoration-center 41 | bottom 50px 42 | -------------------------------------------------------------------------------- /lib/kits/core-ui/page-decorations.ts: -------------------------------------------------------------------------------- 1 | import Mustache from 'mustache'; 2 | import {V2PageDecoration} from '../core'; 3 | import './page-decorations.styl'; 4 | 5 | const defaultTemplate = `\ 6 | {{#pageDecoration.website_link}} 7 | 12 | {{/pageDecoration.website_link}}\ 13 | `; 14 | 15 | const PageDecorations = () => { 16 | const pageDecorationsContainer = document.querySelector( 17 | '.sgn-page_decorations' 18 | ); 19 | const pageDecorationTemplate = document.getElementById( 20 | 'sgn-sdk-paged-publication-viewer-page-decorations-template' 21 | ); 22 | 23 | const render = ({ 24 | pageDecorations, 25 | aspectRatio 26 | }: { 27 | pageDecorations: V2PageDecoration[]; 28 | aspectRatio: number; 29 | }) => { 30 | if (pageDecorationsContainer?.innerHTML) { 31 | pageDecorationsContainer.innerHTML = ''; 32 | } 33 | 34 | const pageDecorationsEls = document.createElement('div'); 35 | 36 | const filteredPageDecorations = pageDecorations?.filter( 37 | (pageDecor) => pageDecor?.website_link 38 | ); 39 | 40 | filteredPageDecorations?.forEach((pageDecoration) => { 41 | if ( 42 | pageDecoration && 43 | pageDecoration.website_link && 44 | getHostname(pageDecoration.website_link) 45 | ) { 46 | const el = document.createElement('div'); 47 | const position = 48 | pageDecorations?.length <= 1 49 | ? 'center' 50 | : pageDecoration.page_number % 2 == 0 51 | ? 'left' 52 | : 'right'; 53 | 54 | const bgImgDimension = getPubImageDimension( 55 | aspectRatio, 56 | pageDecorations.length 57 | ); 58 | 59 | el.classList.add('sgn-pagedecoration'); 60 | el.classList.add(`sgn-pagedecoration-${position}`); 61 | el.innerHTML = Mustache.render( 62 | pageDecorationTemplate?.innerHTML || defaultTemplate, 63 | { 64 | pageDecoration: { 65 | ...pageDecoration, 66 | hostname: 67 | pageDecoration.website_link_title || 68 | getHostname(pageDecoration.website_link) 69 | } 70 | } 71 | ); 72 | 73 | if (position === 'left') { 74 | el.style.left = `calc(50% - ${bgImgDimension.width / 2}px)`; 75 | } else if (position === 'right') { 76 | el.style.left = `calc(50% + ${bgImgDimension.width / 2}px)`; 77 | } 78 | 79 | pageDecorationsEls?.appendChild(el); 80 | } 81 | }); 82 | 83 | pageDecorationsContainer?.appendChild(pageDecorationsEls); 84 | 85 | return pageDecorationsContainer; 86 | }; 87 | 88 | const hide = () => 89 | pageDecorationsContainer?.classList.add('sgn-pagedecoration-hidden'); 90 | 91 | const show = () => 92 | pageDecorationsContainer?.classList.remove('sgn-pagedecoration-hidden'); 93 | 94 | const getHostname = (link = '') => { 95 | try { 96 | const url = new URL(link); 97 | 98 | const hostnameArr = url.hostname.split('.'); 99 | const [subDomain, secondDomain, topDomain] = hostnameArr; 100 | 101 | return subDomain === 'www' 102 | ? [secondDomain, topDomain].join('.') 103 | : url.hostname; 104 | } catch (e) { 105 | console.log('Error:', e?.message); 106 | 107 | return null; 108 | } 109 | }; 110 | 111 | const getPubImageDimension = (aspectRatio: number, pageCount: number) => { 112 | const versoPageSpreadEl = 113 | document.querySelector('.verso__scroller'); 114 | const pageElWidth = (versoPageSpreadEl?.offsetWidth || 0) / pageCount; 115 | const pageElHeight = versoPageSpreadEl?.offsetHeight || 0; 116 | const aspectRatioWidth = pageElHeight / aspectRatio; 117 | const aspectRatioHeight = pageElWidth * aspectRatio; 118 | 119 | return { 120 | width: 121 | aspectRatioWidth < pageElWidth ? aspectRatioWidth : pageElWidth, 122 | height: 123 | aspectRatioWidth < pageElWidth 124 | ? pageElHeight 125 | : aspectRatioHeight, 126 | pageElWidth, 127 | pageElHeight 128 | }; 129 | }; 130 | 131 | return {render, hide, show}; 132 | }; 133 | 134 | export default PageDecorations; 135 | -------------------------------------------------------------------------------- /lib/kits/core-ui/popover.styl: -------------------------------------------------------------------------------- 1 | .sgn-popover 2 | fixed top left 3 | right 0 4 | bottom 0 5 | z-index 3 6 | 7 | .sgn-popover__menu 8 | position absolute 9 | z-index 4 10 | outline 0 11 | background-color #fff 12 | color #000 13 | border-radius 12px 14 | max-width 220px 15 | min-width 170px 16 | box-shadow 0 0 8px rgba(#000, 0.2) 17 | 18 | ul 19 | list-style-type none 20 | margin 0 21 | padding 0 22 | 23 | li 24 | margin 0 25 | padding 8px 0 26 | border-bottom 1px solid #e6e6e6 27 | cursor pointer 28 | 29 | &:first-child 30 | padding-top 0 31 | 32 | &:last-child 33 | border-bottom 0 34 | padding-bottom 0 35 | 36 | .sgn-popover__content 37 | padding 8px 38 | 39 | .sgn-popover-item__title 40 | font-size 16px 41 | overflow ellipsis 42 | margin 0 43 | 44 | .sgn-popover-item__subtitle 45 | font-size 14px 46 | margin 0 47 | overflow ellipsis 48 | 49 | .sgn-popover__header 50 | padding 10px 12px 51 | line-height 1 52 | font-size 16px 53 | text-align center 54 | background-color #f9f9f9 55 | border-radius top left 12px 56 | border-radius top right 12px 57 | border-bottom 1px solid #e6e6e6 58 | 59 | .sgn-popover__background 60 | absolute top left 61 | right 0 62 | bottom 0 63 | cursor pointer -------------------------------------------------------------------------------- /lib/kits/core-ui/popover.ts: -------------------------------------------------------------------------------- 1 | import Mustache from 'mustache'; 2 | import MicroEvent from '../../../vendor/microevent'; 3 | import * as keyCodes from '../../key-codes'; 4 | import {off, on} from '../../util'; 5 | import './popover.styl'; 6 | 7 | const defaultTemplate = `\ 8 |
9 |
10 | {{#header}} 11 |
{{header}}
12 | {{/header}} 13 |
14 |
    15 | {{#singleChoiceItems}} 16 |
  • 17 |

    {{item.title}}

    18 | {{#item.subtitle}} 19 |

    {{item.subtitle}}

    20 | {{/item.subtitle}} 21 |
  • 22 | {{/singleChoiceItems}} 23 |
24 |
25 |
\ 26 | `; 27 | 28 | interface PopoverOptions { 29 | header?: string; 30 | singleChoiceItems?: any; 31 | template?: string; 32 | x: number; 33 | y: number; 34 | } 35 | class Popover extends MicroEvent { 36 | el = document.createElement('div'); 37 | backgroundEl = document.createElement('div'); 38 | options: PopoverOptions; 39 | constructor(options: PopoverOptions) { 40 | super(); 41 | this.options = options; 42 | } 43 | 44 | render() { 45 | const {header, singleChoiceItems, template} = this.options; 46 | 47 | this.el.className = 'sgn-popover'; 48 | this.el.setAttribute('tabindex', '-1'); 49 | this.el.innerHTML = Mustache.render(template || defaultTemplate, { 50 | header, 51 | singleChoiceItems: singleChoiceItems?.map((item, index) => ({ 52 | item, 53 | index 54 | })) 55 | }); 56 | 57 | this.position(); 58 | this.addEventListeners(); 59 | 60 | return this; 61 | } 62 | 63 | destroy() { 64 | off(this.el); 65 | 66 | window.removeEventListener('resize', this.resize, false); 67 | window.removeEventListener('scroll', this.scroll, false); 68 | 69 | if (this.el.parentNode) { 70 | this.el.parentNode.removeChild(this.el); 71 | 72 | this.trigger('destroyed'); 73 | } 74 | } 75 | 76 | position() { 77 | let top = this.options.y; 78 | let left = this.options.x; 79 | 80 | const menuEl = 81 | this.el.querySelector('.sgn-popover__menu'); 82 | 83 | if (menuEl && this.el.parentElement) { 84 | const width = menuEl.offsetWidth; 85 | const height = menuEl.offsetHeight; 86 | const parentWidth = this.el.parentElement.offsetWidth; 87 | const parentHeight = this.el.parentElement.offsetHeight; 88 | const boundingRect = this.el.parentElement.getBoundingClientRect(); 89 | 90 | top -= boundingRect.top; 91 | left -= boundingRect.left; 92 | 93 | top -= window.pageYOffset; 94 | left -= window.pageXOffset; 95 | 96 | menuEl.style.top = 97 | top + height > parentHeight 98 | ? parentHeight - height + 'px' 99 | : top + 'px'; 100 | 101 | menuEl.style.left = 102 | left + width > parentWidth 103 | ? parentWidth - width + 'px' 104 | : left + 'px'; 105 | } 106 | } 107 | 108 | addEventListeners() { 109 | const trigger = this.trigger.bind(this); 110 | 111 | this.el.addEventListener('keyup', this.keyUp); 112 | 113 | on(this.el, 'click', '[data-index]', function (e) { 114 | e.preventDefault(); 115 | e.stopPropagation(); 116 | 117 | trigger('selected', {index: this.dataset.index}); 118 | }); 119 | 120 | on(this.el, 'click', '[data-close]', (e) => { 121 | e.preventDefault(); 122 | e.stopPropagation(); 123 | 124 | this.destroy(); 125 | }); 126 | 127 | on(this.el, 'click', '.sgn-popover__menu', (e) => { 128 | e.stopPropagation(); 129 | }); 130 | 131 | window.addEventListener('resize', this.resize, false); 132 | window.addEventListener('scroll', this.scroll, false); 133 | } 134 | 135 | keyUp = (e) => { 136 | if (e.keyCode === keyCodes.ESC) this.destroy(); 137 | }; 138 | 139 | resize = () => { 140 | this.destroy(); 141 | }; 142 | 143 | scroll = () => { 144 | this.destroy(); 145 | }; 146 | } 147 | 148 | export default Popover; 149 | -------------------------------------------------------------------------------- /lib/kits/core-ui/single-choice-popover.ts: -------------------------------------------------------------------------------- 1 | import Popover from './popover'; 2 | 3 | export default function singleChoicePopover( 4 | { 5 | items, 6 | el, 7 | header, 8 | x, 9 | y 10 | }: {items; el: HTMLElement; header?: string; x: number; y: number}, 11 | callback 12 | ) { 13 | let popover: Popover | undefined; 14 | 15 | if (items.length === 1) { 16 | callback(items[0]); 17 | } else if (items.length > 1) { 18 | popover = new Popover({header, x, y, singleChoiceItems: items}); 19 | 20 | popover.bind('selected', (e) => { 21 | callback(items[e.index]); 22 | 23 | popover?.destroy(); 24 | }); 25 | 26 | popover.bind('destroyed', () => { 27 | el.focus(); 28 | }); 29 | 30 | el.appendChild(popover.el); 31 | popover.render().el.focus(); 32 | } 33 | 34 | return { 35 | destroy() { 36 | popover?.destroy(); 37 | } 38 | }; 39 | } 40 | -------------------------------------------------------------------------------- /lib/kits/core/index.ts: -------------------------------------------------------------------------------- 1 | export {default as request} from './request'; 2 | 3 | export type V2App = {id: number; name: string}; 4 | 5 | export type V2Currency = 'DKK' | 'EUR' | 'NOK' | 'PLN' | 'SEK' | 'ISK' | 'RON'; 6 | 7 | export type V2CountryCode = 8 | | 'DE' 9 | | 'DK' 10 | | 'GL' 11 | | 'NL' 12 | | 'NO' 13 | | 'PL' 14 | | 'SE' 15 | | 'FI' 16 | | 'SJ' 17 | | 'IS' 18 | | 'RO'; 19 | 20 | export interface V2Branding { 21 | name: string; 22 | website?: string; 23 | description?: string; 24 | color: string; 25 | logo: string; 26 | pageflip: {color: string; logo: string}; 27 | } 28 | interface V2Images { 29 | thumb: string; 30 | view: string; 31 | zoom: string; 32 | } 33 | interface V2Pricing { 34 | pre_price: number | null; 35 | price: number; 36 | currency: V2Currency; 37 | } 38 | interface V2Quantity { 39 | pieces: {from: number; to: number}; 40 | size: {from: number; to: number}; 41 | unit: {si: {factor: number; symbol: string}; symbol: string} | null; 42 | } 43 | 44 | export interface V2Offer { 45 | branding: V2Branding; 46 | id: string; 47 | heading: string; 48 | description: string; 49 | catalog_page: number | null; 50 | catalog_id: string | null; 51 | dealer_id: string; 52 | dealer: V2Dealer; 53 | images: V2Images; 54 | links: {webshop: string | null}; 55 | pricing: V2Pricing; 56 | publish: string; 57 | run_from: string; 58 | run_till: string; 59 | quantity: V2Quantity; 60 | /** @deprecated */ 61 | store_id?: null; 62 | } 63 | 64 | type DayOfWeek = 65 | | 'monday' 66 | | 'tuesday' 67 | | 'wednesday' 68 | | 'thursday' 69 | | 'friday' 70 | | 'saturday' 71 | | 'sunday'; 72 | export interface V2Store { 73 | id: string; 74 | ern: string; 75 | graph_id: string; 76 | street: string; 77 | city: string; 78 | zip_code: string; 79 | country: {unsubscribe_print_url?: string; id: V2CountryCode}; 80 | openingHours?: ( 81 | | {opens: string; closes: string; day_of_week: DayOfWeek} 82 | | { 83 | opens: string; 84 | closes: string; 85 | valid_from: string; 86 | valid_until: string; 87 | } 88 | | {valid_from: string; valid_until: string} 89 | | {day_of_week: DayOfWeek} 90 | )[]; 91 | latitude: number; 92 | longitude: number; 93 | dealer_url: string; 94 | dealer_id: string; 95 | dealer: V2Dealer; 96 | branding: V2Branding; 97 | category_ids: string[]; 98 | /** @deprecated */ 99 | facebook_page_id?: null; 100 | /** @deprecated */ 101 | twitter_handle?: null; 102 | /** @deprecated */ 103 | youtube_user_id?: null; 104 | /** @deprecated */ 105 | contact?: null; 106 | } 107 | 108 | export interface V2Catalog { 109 | id: string; 110 | ern: string; 111 | run_from: string; 112 | store_id: string | null; 113 | store_url: null; 114 | images: V2Images; 115 | types: ('incito' | 'paged')[]; 116 | incito_publication_id: string | null; 117 | all_stores: boolean; 118 | dealer_url: string; 119 | branding: V2Branding; 120 | pdf_url: string; 121 | label: string; 122 | run_till: string; 123 | /** @deprecated */ 124 | pages?: {view: []; thumb: []; zoom: []}; 125 | background: string; 126 | category_ids: []; 127 | offer_count: number; 128 | page_count: number; 129 | dealer_id: string; 130 | dealer: V2Dealer; 131 | dimensions: {width: 1; height: number}; 132 | } 133 | export interface V2Page { 134 | view: string; 135 | thumb: string; 136 | zoom: string; 137 | } 138 | export interface V2Dealer { 139 | id: string; 140 | ern: string; 141 | markets: {slug: string; country_code: V2CountryCode}[]; 142 | /** @deprecated */ 143 | graph_id?: null; 144 | name: string; 145 | website: string; 146 | description: string; 147 | logo: string; 148 | color: string; 149 | pageflip: {logo: string; color: string}; 150 | country: { 151 | id: V2CountryCode; 152 | /** @deprecated */ 153 | unsubscribe_print_url: null; 154 | }; 155 | description_markdown: string; 156 | favorite_count: number; 157 | is_incito_supported: boolean; 158 | locale: string; 159 | category_ids: number[]; 160 | is_content_public: boolean; 161 | } 162 | 163 | export interface V2Hotspot { 164 | type: string; 165 | locations: Record; 166 | id: string; 167 | run_from: number; 168 | run_till: number; 169 | heading: string; 170 | webshop?: any; 171 | offer: { 172 | id: string; 173 | ern: string; 174 | heading: string; 175 | pricing: V2Pricing; 176 | quantity: V2Quantity; 177 | run_from: string; 178 | run_till: string; 179 | publish: string; 180 | }; 181 | id_collection: {type: 'id'; provider: 'shopgun-core'; value: string}[]; 182 | } 183 | 184 | export interface V2Dealerfront { 185 | catalogs: V2Catalog[]; 186 | dealer: V2Dealer; 187 | } 188 | 189 | export interface V2PageDecoration { 190 | page_number: number; 191 | title: string | null; 192 | website_link: string | null; 193 | website_link_title?: string | null; 194 | hotspots?: 195 | | { 196 | x1: number; 197 | x2: number; 198 | y1: number; 199 | y2: number; 200 | rotate: number; 201 | embed_link: string; 202 | link: string; 203 | }[] 204 | | null; 205 | } 206 | -------------------------------------------------------------------------------- /lib/kits/core/request.ts: -------------------------------------------------------------------------------- 1 | import fetch from 'cross-fetch'; 2 | import {error} from '../../util'; 3 | 4 | async function request( 5 | { 6 | coreUrl, 7 | url: rawUrl = '', 8 | apiKey, 9 | qs, 10 | method = 'get', 11 | headers, 12 | body 13 | }: { 14 | coreUrl?: string; 15 | url: string | URL; 16 | apiKey?: string; 17 | qs?: Record; 18 | method?: RequestInit['method']; 19 | headers?: Record; 20 | body?: RequestInit['body']; 21 | }, 22 | callback?: (error: Error | null, result?: T | null) => void 23 | ): Promise> { 24 | try { 25 | const url = new URL(rawUrl, coreUrl); 26 | 27 | if (!apiKey) { 28 | throw new Error( 29 | '`apiKey` needs to be configured, please see README' 30 | ); 31 | } 32 | 33 | // @ts-expect-error 34 | for (const key in qs) url.searchParams.append(key, qs[key]); 35 | 36 | const response = await fetch(String(url), { 37 | method, 38 | body, 39 | headers: { 40 | Accept: 'application/json', 41 | ...headers, 42 | 'X-Api-Key': apiKey 43 | }, 44 | credentials: 'same-origin' 45 | }); 46 | if ( 47 | (response.status >= 200 && response.status < 300) || 48 | response.status === 304 49 | ) { 50 | const json = await response.json(); 51 | 52 | callback?.(null, json); 53 | 54 | return json; 55 | } 56 | throw error(new Error('Core API error'), { 57 | code: 'CoreAPIError', 58 | statusCode: response.status 59 | }); 60 | } catch (error) { 61 | callback?.(error); 62 | 63 | throw error; 64 | } 65 | } 66 | 67 | export default request; 68 | -------------------------------------------------------------------------------- /lib/kits/events/index.ts: -------------------------------------------------------------------------------- 1 | export {default as Tracker} from './tracker'; 2 | -------------------------------------------------------------------------------- /lib/kits/incito-publication/controls.ts: -------------------------------------------------------------------------------- 1 | import Viewer from './viewer'; 2 | 3 | export default class Controls { 4 | viewer: Viewer; 5 | progressEl: HTMLElement | null; 6 | constructor(viewer: Viewer) { 7 | this.viewer = viewer; 8 | this.progressEl = this.viewer.el.querySelector('.sgn-incito__progress'); 9 | 10 | if (this.progressEl) { 11 | this.scroll(); 12 | window.addEventListener('scroll', this.scroll, false); 13 | } 14 | } 15 | 16 | destroy = () => { 17 | window.removeEventListener('scroll', this.scroll, false); 18 | }; 19 | 20 | scroll = () => { 21 | const progress = Math.round( 22 | (window.pageYOffset / 23 | (document.body.scrollHeight - window.innerHeight)) * 24 | 100 25 | ); 26 | 27 | this.progressEl!.textContent = `${progress} %`; 28 | 29 | this.viewer.trigger('progress', {progress}); 30 | }; 31 | } 32 | -------------------------------------------------------------------------------- /lib/kits/incito-publication/event-tracking.ts: -------------------------------------------------------------------------------- 1 | import MicroEvent, {EventArg} from '../../../vendor/microevent'; 2 | import Incito from '../../incito-browser/incito'; 3 | import type {V2Catalog} from '../core'; 4 | import type {Tracker} from '../events'; 5 | 6 | class IncitoPublicationEventTracking extends MicroEvent { 7 | eventTracker: Tracker | undefined; 8 | details: V2Catalog | undefined; 9 | constructor(eventTracker?: Tracker, details?: V2Catalog) { 10 | super(); 11 | this.eventTracker = eventTracker; 12 | this.details = details; 13 | } 14 | 15 | trackOpened() { 16 | if (!this.eventTracker || !this.details) return this; 17 | 18 | this.eventTracker.trackIncitoPublicationOpened({ 19 | 'ip.paged': this.details.types.indexOf('paged') > -1, 20 | 'ip.id': this.details.id, 21 | vt: this.eventTracker.createViewToken(this.details.id) 22 | }); 23 | 24 | return this; 25 | } 26 | trackIncitoPublicationOpenedMinimumMosMs = 300; 27 | sectionVisibility: Map = new Map(); 28 | onSectionVisible = ({ 29 | sectionId, 30 | sectionPosition 31 | }: EventArg) => { 32 | if (!this.eventTracker || !this.details) return this; 33 | 34 | const sectionKey = `${sectionId}-${sectionPosition}`; 35 | this.sectionVisibility.set(sectionKey, Date.now()); 36 | }; 37 | onSectionHidden = ({ 38 | sectionId, 39 | sectionPosition 40 | }: EventArg) => { 41 | if (!this.eventTracker || !this.details) return this; 42 | 43 | const sectionKey = `${sectionId}-${sectionPosition}`; 44 | const visibleFrom = this.sectionVisibility.get(sectionKey); 45 | this.sectionVisibility.delete(sectionKey); 46 | if (!visibleFrom) return; 47 | 48 | const mos = Date.now() - visibleFrom; 49 | if (mos <= this.trackIncitoPublicationOpenedMinimumMosMs) return; 50 | 51 | this.eventTracker.trackIncitoPublicationSectionOpened({ 52 | 'ip.id': this.details.id, 53 | 'ips.id': sectionId, 54 | 'ips.p': sectionPosition, 55 | _t: Math.round(visibleFrom / 1000), 56 | mos, 57 | vt: this.eventTracker.createViewToken(this.details.id, sectionId) 58 | }); 59 | }; 60 | } 61 | 62 | export default IncitoPublicationEventTracking; 63 | -------------------------------------------------------------------------------- /lib/kits/incito-publication/index.ts: -------------------------------------------------------------------------------- 1 | export {default as Viewer} from './viewer'; 2 | 3 | export {default as Bootstrapper} from './bootstrapper'; 4 | -------------------------------------------------------------------------------- /lib/kits/incito-publication/viewer.styl: -------------------------------------------------------------------------------- 1 | .sgn__incito 2 | position relative 3 | outline 0 4 | letter-spacing normal 5 | 6 | &.sgn-incito--started 7 | .sgn-incito__progress 8 | display inline-block 9 | 10 | .sgn-incito__progress 11 | box-sizing border-box 12 | position fixed 13 | left 50% 14 | bottom 20px 15 | display none 16 | z-index 2 17 | background-color #000 18 | color #fff 19 | width 100px 20 | margin-left -50px 21 | text-align center 22 | padding 8px 0 23 | border-radius 6px 24 | font-weight bold 25 | font-size 14px 26 | font-family "Lucida Sans Unicode", "Lucida Grande", sans-serif 27 | letter-spacing -1px 28 | line-height 1 29 | 30 | &:hover 31 | opacity 1 32 | 33 | @media (min-width: 700px) 34 | padding 16px 0 35 | font-size 18px 36 | 37 | @supports (unquote('max(20px, env(safe-area-inset-bottom))')) 38 | bottom unquote('max(20px, env(safe-area-inset-bottom))') -------------------------------------------------------------------------------- /lib/kits/incito-publication/viewer.test-jsdom.ts: -------------------------------------------------------------------------------- 1 | import {V2Catalog} from '../core'; 2 | import Viewer from './viewer'; 3 | 4 | const dummyIncito = { 5 | version: '1.0.0', 6 | root_view: {}, 7 | id: 'd1b667mmocp' 8 | } as const; 9 | 10 | describe('SGN.IncitoPublicationKit.Viewer', () => { 11 | test('Tracks Incito Publication Opened', () => { 12 | const fakeEventTracker = { 13 | trackIncitoPublicationOpened: jest.fn(() => {}), 14 | createViewToken: jest.fn((a) => a + '-vt') 15 | }; 16 | 17 | const mountPoint = document.createElement('div'); 18 | 19 | const viewer = new Viewer(mountPoint, { 20 | details: {id: 'incito-id', types: ['paged', 'incito']} as V2Catalog, 21 | incito: dummyIncito, 22 | eventTracker: fakeEventTracker as any 23 | }); 24 | 25 | viewer.start(); 26 | 27 | expect( 28 | fakeEventTracker.trackIncitoPublicationOpened.mock.calls.length 29 | ).toBe(1); 30 | expect( 31 | (fakeEventTracker as any).trackIncitoPublicationOpened.mock 32 | .calls[0][0] 33 | ).toEqual({ 34 | 'ip.id': 'incito-id', 35 | 'ip.paged': true, 36 | vt: 'incito-id-vt' 37 | }); 38 | }); 39 | }); 40 | -------------------------------------------------------------------------------- /lib/kits/incito-publication/viewer.ts: -------------------------------------------------------------------------------- 1 | import MicroEvent from '../../../vendor/microevent'; 2 | import Incito from '../../incito-browser/incito'; 3 | import {IIncito} from '../../incito-browser/types'; 4 | import {V2Catalog} from '../core'; 5 | import {Tracker} from '../events'; 6 | import EventTracking from './event-tracking'; 7 | import './viewer.styl'; 8 | 9 | interface ViewerInit { 10 | incito: IIncito; 11 | eventTracker?: Tracker; 12 | details?: V2Catalog; 13 | } 14 | class Viewer extends MicroEvent { 15 | static Incito = Incito; 16 | el: any; 17 | options: ViewerInit; 18 | incito: Incito; 19 | _eventTracking: EventTracking; 20 | constructor(el: HTMLElement, options: ViewerInit) { 21 | super(); 22 | 23 | this.el = el; 24 | this.options = options; 25 | this.incito = new Incito(this.el, { 26 | incito: this.options.incito 27 | }); 28 | this._eventTracking = new EventTracking( 29 | this.options.eventTracker, 30 | this.options.details 31 | ); 32 | } 33 | 34 | start() { 35 | this.incito.bind( 36 | 'sectionVisible', 37 | this._eventTracking.onSectionVisible 38 | ); 39 | this.incito.bind('sectionHidden', this._eventTracking.onSectionHidden); 40 | this.incito.start(); 41 | this.el.classList.add('sgn-incito--started'); 42 | this._eventTracking.trackOpened(); 43 | 44 | return this; 45 | } 46 | 47 | destroy() { 48 | this.incito.destroy(); 49 | } 50 | } 51 | 52 | export default Viewer; 53 | -------------------------------------------------------------------------------- /lib/kits/paged-publication/event-tracking.ts: -------------------------------------------------------------------------------- 1 | import MicroEvent from '../../../vendor/microevent'; 2 | import Tracker from '../events/tracker'; 3 | import PagedPublicationPageSpread from './page-spread'; 4 | 5 | class PagedPublicationEventTracking extends MicroEvent { 6 | hidden = true; 7 | pageSpread: null | PagedPublicationPageSpread = null; 8 | eventTracker: Tracker; 9 | id: string; 10 | constructor(eventTracker: Tracker, id: string) { 11 | super(); 12 | this.eventTracker = eventTracker; 13 | this.id = id; 14 | 15 | this.bind('appeared', this.appeared); 16 | this.bind('disappeared', this.disappeared); 17 | this.bind('beforeNavigation', this.beforeNavigation); 18 | this.bind('afterNavigation', this.afterNavigation); 19 | this.bind('attemptedNavigation', this.attemptedNavigation); 20 | this.bind('panStart', this.panStart); 21 | this.bind('destroyed', this.destroy); 22 | } 23 | 24 | destroy = () => { 25 | this.pageSpreadDisappeared(); 26 | }; 27 | 28 | trackOpened() { 29 | if (!this.eventTracker) return this; 30 | 31 | this.eventTracker.trackPagedPublicationOpened({ 32 | 'pp.id': this.id, 33 | vt: this.eventTracker.createViewToken(this.id) 34 | }); 35 | 36 | return this; 37 | } 38 | 39 | trackPageSpreadAppeared(pageNumbers: number[]) { 40 | if (!this.eventTracker) return this; 41 | 42 | pageNumbers.forEach((pageNumber) => { 43 | this.eventTracker.trackPagedPublicationPageOpened({ 44 | 'pp.id': this.id, 45 | 'ppp.n': pageNumber, 46 | vt: this.eventTracker.createViewToken( 47 | this.id, 48 | String(pageNumber) 49 | ) 50 | }); 51 | }); 52 | 53 | return this; 54 | } 55 | 56 | appeared = (e) => { 57 | this.pageSpreadAppeared(e.pageSpread); 58 | }; 59 | 60 | disappeared = () => { 61 | this.pageSpreadDisappeared(); 62 | }; 63 | 64 | beforeNavigation = () => { 65 | this.pageSpreadDisappeared(); 66 | }; 67 | 68 | afterNavigation = (e) => { 69 | this.pageSpreadAppeared(e.pageSpread, e.verso); 70 | }; 71 | 72 | attemptedNavigation = (e) => { 73 | this.pageSpreadAppeared(e.pageSpread); 74 | }; 75 | 76 | panStart = (e) => { 77 | if (e.scale === 1) this.pageSpreadDisappeared(); 78 | }; 79 | 80 | pageSpreadAppeared( 81 | pageSpread: PagedPublicationPageSpread, 82 | verso?: {newPosition: number; previousPosition: number} 83 | ) { 84 | if (pageSpread && this.hidden) { 85 | this.pageSpread = pageSpread; 86 | this.hidden = false; 87 | 88 | if (verso && verso.newPosition !== verso.previousPosition) { 89 | this.trackPageSpreadAppeared( 90 | pageSpread.getPages().map((page) => page.pageNumber) 91 | ); 92 | } 93 | } 94 | } 95 | 96 | pageSpreadDisappeared() { 97 | if (this.pageSpread && !this.hidden) { 98 | this.hidden = true; 99 | this.pageSpread = null; 100 | } 101 | } 102 | } 103 | 104 | export default PagedPublicationEventTracking; 105 | -------------------------------------------------------------------------------- /lib/kits/paged-publication/index.ts: -------------------------------------------------------------------------------- 1 | export {default as Viewer} from './viewer'; 2 | 3 | export {default as Bootstrapper} from './bootstrapper'; 4 | -------------------------------------------------------------------------------- /lib/kits/paged-publication/page-spread.ts: -------------------------------------------------------------------------------- 1 | import MicroEvent from '../../../vendor/microevent'; 2 | import {Page, PageMode} from './page-spreads'; 3 | 4 | const loadImage = ( 5 | src: string, 6 | callback: (error: Error | null, element?: HTMLImageElement) => void 7 | ) => { 8 | const img = new Image(); 9 | img.onload = ({target}) => callback(null, target as HTMLImageElement); 10 | img.onerror = () => { 11 | callback(new Error('Failed to load page image')); 12 | }; 13 | img.src = src; 14 | 15 | return img; 16 | }; 17 | 18 | interface PagedPublicationPageSpreadInit { 19 | id: string; 20 | pages: Page[]; 21 | maxZoomScale: number; 22 | width: number; 23 | pageMode: PageMode; 24 | } 25 | class PagedPublicationPageSpread extends MicroEvent<{ 26 | pageLoaded: [{pageSpreadId: string; page: Page}]; 27 | pagesLoaded: [{pageSpreadId: string; pages: Page[]}]; 28 | }> { 29 | contentsRendered = false; 30 | hotspotsRendered = false; 31 | el: HTMLElement; 32 | options: PagedPublicationPageSpreadInit; 33 | constructor(options: PagedPublicationPageSpreadInit) { 34 | super(); 35 | this.options = options; 36 | this.el = this.renderEl(); 37 | } 38 | 39 | getId() { 40 | return this.options.id; 41 | } 42 | 43 | getEl() { 44 | return this.el; 45 | } 46 | 47 | getPages() { 48 | return this.options.pages; 49 | } 50 | 51 | renderEl() { 52 | const el = document.createElement('div'); 53 | const pageIds = this.getPages().map((page) => page.id); 54 | 55 | el.className = 'verso__page-spread sgn-pp__page-spread'; 56 | 57 | el.dataset.id = this.getId(); 58 | el.dataset.type = 'page'; 59 | el.dataset.width = String(this.options.width); 60 | el.dataset.pageIds = pageIds.join(','); 61 | el.dataset.maxZoomScale = String(this.options.maxZoomScale); 62 | el.dataset.zoomable = String(false); 63 | 64 | return el; 65 | } 66 | 67 | renderContents() { 68 | const pageSpreadId = this.getId(); 69 | const el = this.getEl(); 70 | const pages = this.getPages(); 71 | const pageCount = pages.length; 72 | let imageLoads = 0; 73 | 74 | let maxPageWidth = el.clientWidth * (window.devicePixelRatio || 1); 75 | if (this.options.pageMode === 'double') maxPageWidth = maxPageWidth / 2; 76 | 77 | const useLargeImage = maxPageWidth > 700; 78 | 79 | pages.forEach((page, i) => { 80 | const image = useLargeImage 81 | ? page.images.large 82 | : page.images.medium; 83 | 84 | const pageEl = document.createElement('div'); 85 | const loaderEl = document.createElement('div'); 86 | 87 | pageEl.className = 'sgn-pp__page verso__page'; 88 | if (page.id) pageEl.dataset.id = page.id; 89 | 90 | if (pageCount === 2) { 91 | pageEl.className += 92 | i === 0 ? ' verso-page--verso' : ' verso-page--recto'; 93 | } 94 | 95 | pageEl.appendChild(loaderEl); 96 | el.appendChild(pageEl); 97 | 98 | loaderEl.className = 'sgn-pp-page__loader'; 99 | loaderEl.innerHTML = `${page.label}`; 100 | 101 | loadImage(image, (err, img) => { 102 | if (err || !img) { 103 | loaderEl.innerHTML = '!'; 104 | 105 | return console.error(err); 106 | } 107 | 108 | const isComplete = ++imageLoads === pageCount; 109 | 110 | pageEl.style.backgroundImage = `url(${image})`; 111 | pageEl.dataset.width = String(img.width); 112 | pageEl.dataset.height = String(img.height); 113 | pageEl.innerHTML = ' '; 114 | 115 | if (isComplete) el.dataset.zoomable = String(true); 116 | 117 | this.trigger('pageLoaded', {pageSpreadId, page}); 118 | if (isComplete) { 119 | this.trigger('pagesLoaded', {pageSpreadId, pages}); 120 | } 121 | }); 122 | }); 123 | 124 | this.contentsRendered = true; 125 | 126 | return this; 127 | } 128 | 129 | clearContents() { 130 | this.el.innerHTML = ''; 131 | this.contentsRendered = false; 132 | 133 | return this; 134 | } 135 | 136 | zoomIn() { 137 | const pages = this.getPages(); 138 | 139 | this.el 140 | .querySelectorAll('.sgn-pp__page') 141 | .forEach((pageEl) => { 142 | const id = pageEl.dataset.id; 143 | const image = pages.find((page) => page.id === id)!.images 144 | .large; 145 | 146 | loadImage(image, (err) => { 147 | if (err) return console.error(err); 148 | 149 | if (this.el.dataset.active === 'true') { 150 | pageEl.dataset.image = pageEl.style.backgroundImage; 151 | pageEl.style.backgroundImage = `url(${image})`; 152 | } 153 | }); 154 | }); 155 | } 156 | 157 | zoomOut() { 158 | this.el 159 | .querySelectorAll('.sgn-pp__page[data-image]') 160 | .forEach((pageEl) => { 161 | pageEl.style.backgroundImage = pageEl.dataset.image!; 162 | 163 | delete pageEl.dataset.image; 164 | }); 165 | } 166 | } 167 | 168 | export default PagedPublicationPageSpread; 169 | -------------------------------------------------------------------------------- /lib/kits/paged-publication/page-spreads.ts: -------------------------------------------------------------------------------- 1 | import MicroEvent from '../../../vendor/microevent'; 2 | import {chunk} from '../../util'; 3 | import PagedPublicationPageSpread from './page-spread'; 4 | 5 | export interface Page { 6 | id: string; 7 | label: string; 8 | pageNumber: number; 9 | images: {medium: string; large: string}; 10 | } 11 | export type PageMode = 'single' | 'double'; 12 | interface PagedPublicationPageSpreadsInit { 13 | pages: Page[]; 14 | width: number; 15 | maxZoomScale: number; 16 | } 17 | class PagedPublicationPageSpreads extends MicroEvent<{ 18 | pageLoaded: [{pageSpreadId: string; page: Page}]; 19 | pagesLoaded: [{pageSpreadId: string; pages: Page[]}]; 20 | }> { 21 | collection: PagedPublicationPageSpread[] = []; 22 | ids: Record = {}; 23 | options: PagedPublicationPageSpreadsInit; 24 | constructor(options: PagedPublicationPageSpreadsInit) { 25 | super(); 26 | this.options = options; 27 | } 28 | 29 | get(id: string) { 30 | return this.ids[id]; 31 | } 32 | 33 | getFrag() { 34 | const frag = document.createDocumentFragment(); 35 | 36 | this.collection.forEach((pageSpread) => { 37 | frag.appendChild(pageSpread.el); 38 | }); 39 | 40 | return frag; 41 | } 42 | 43 | update(pageMode: PageMode = 'single') { 44 | const pageSpreads: Page[][] = []; 45 | const ids: typeof this.ids = {}; 46 | const pages = this.options.pages.slice(); 47 | const {width, maxZoomScale} = this.options; 48 | 49 | if (pageMode === 'single') { 50 | pages.forEach((page) => { 51 | pageSpreads.push([page]); 52 | }); 53 | } else { 54 | const firstPage = pages.shift(); 55 | const lastPage = pages.length % 2 === 1 ? pages.pop() : null; 56 | const midstPageSpreads = chunk(pages, 2); 57 | 58 | if (firstPage) pageSpreads.push([firstPage]); 59 | 60 | midstPageSpreads.forEach((midstPages) => { 61 | pageSpreads.push(midstPages); 62 | }); 63 | 64 | if (lastPage) pageSpreads.push([lastPage]); 65 | } 66 | 67 | this.collection = pageSpreads.map((pages, i) => { 68 | const id = `${pageMode}-${i}`; 69 | const pageSpread = new PagedPublicationPageSpread({ 70 | width, 71 | pageMode, 72 | maxZoomScale, 73 | pages, 74 | id 75 | }); 76 | 77 | pageSpread.bind('pageLoaded', (e) => { 78 | this.trigger('pageLoaded', e); 79 | }); 80 | pageSpread.bind('pagesLoaded', (e) => { 81 | this.trigger('pagesLoaded', e); 82 | }); 83 | 84 | ids[id] = pageSpread; 85 | 86 | return pageSpread; 87 | }); 88 | this.ids = ids; 89 | 90 | return this; 91 | } 92 | } 93 | 94 | export default PagedPublicationPageSpreads; 95 | -------------------------------------------------------------------------------- /lib/sgn-sdk.ts: -------------------------------------------------------------------------------- 1 | import Config from './config'; 2 | import { 3 | IncitoPublication, 4 | ListPublications, 5 | PagedPublication 6 | } from './kits/core-ui'; 7 | import request from './kits/core/request'; 8 | import Tracker from './kits/events/tracker'; 9 | import { 10 | Bootstrapper as IncitoBootstrapper, 11 | Viewer as IncitoViewer 12 | } from './kits/incito-publication'; 13 | import { 14 | Bootstrapper as PagedBootstrapper, 15 | Viewer as PagedViewer 16 | } from './kits/paged-publication'; 17 | import * as clientLocal from './storage/client-local'; 18 | import './stylus/sgn.styl'; 19 | import {error, isBrowser} from './util'; 20 | import {coreUrlStaging, eventsTrackUrlStaging} from './config-defaults'; 21 | 22 | export const config = new Config(); 23 | config.bind('change', (changedAttributes) => { 24 | const newEventTracker: Tracker | undefined = changedAttributes.eventTracker; 25 | const newApiKey: string | undefined = changedAttributes.apiKey; 26 | if ( 27 | (newApiKey || newEventTracker) && 28 | (newEventTracker || config.get('eventTracker'))?.trackId === 29 | (newApiKey || config.get('apiKey')) 30 | ) { 31 | throw error( 32 | new Error( 33 | 'Track identifier must not be identical to app key. Go to https://etilbudsavis.dk/developers/apps to get a track identifier for your app' 34 | ) 35 | ); 36 | } 37 | 38 | // default eventsTrackUrl 39 | if (newEventTracker && !newEventTracker.eventsTrackUrl) { 40 | newEventTracker.setEventsTrackUrl(config.get('eventsTrackUrl')); 41 | } 42 | 43 | const newEventsTrackUrl = changedAttributes.eventsTrackUrl; 44 | if (newEventsTrackUrl && config.get('eventTracker')) { 45 | config 46 | .get('eventTracker') 47 | .setEventsTrackUrl(newEventsTrackUrl); 48 | } 49 | }); 50 | 51 | if (isBrowser()) { 52 | // Autoconfigure the SDK. 53 | const scriptEl = document.currentScript; 54 | 55 | if (scriptEl instanceof HTMLScriptElement) { 56 | const apiKey = scriptEl.dataset.apiKey || scriptEl.dataset.appKey; 57 | const trackId = scriptEl.dataset.trackId; 58 | const component = scriptEl.dataset.component; 59 | const isStaging = scriptEl.dataset.environment === 'staging'; 60 | const scriptConfig: { 61 | apiKey?: string; 62 | eventTracker?: Tracker; 63 | coreUrl?: string; 64 | } = {}; 65 | 66 | if (apiKey) { 67 | scriptConfig.apiKey = apiKey; 68 | } 69 | 70 | if (isStaging) { 71 | scriptConfig.coreUrl = coreUrlStaging; 72 | } 73 | 74 | if (trackId) { 75 | scriptConfig.eventTracker = new Tracker({ 76 | trackId, 77 | ...(isStaging && {eventsTrackUrl: eventsTrackUrlStaging}) 78 | }); 79 | } 80 | 81 | config.set(scriptConfig); 82 | 83 | if (component === 'paged-publication-viewer') { 84 | PagedPublication(scriptEl, config.shadow()).render(); 85 | } 86 | 87 | if (component === 'incito-publication-viewer') { 88 | IncitoPublication(scriptEl, config.shadow()).render(); 89 | } 90 | 91 | if (component === 'list-publications') { 92 | ListPublications(scriptEl, config.shadow()).render(); 93 | } 94 | } 95 | } 96 | 97 | export * as CoreUIKit from './kits/core-ui'; 98 | export * as EventsKit from './kits/events'; 99 | export * as translations from './translations'; 100 | export * as util from './util'; 101 | export const storage = {local: clientLocal}; 102 | export const CoreKit = { 103 | request: ( 104 | options: Parameters[0], 105 | callback: Parameters[1] 106 | ) => request(config.shadow(options), callback) 107 | }; 108 | export const PagedPublicationKit = { 109 | Bootstrapper: function ( 110 | options: ConstructorParameters[0] 111 | ) { 112 | return new PagedBootstrapper(config.shadow(options)); 113 | }, 114 | Viewer: PagedViewer 115 | }; 116 | export const IncitoPublicationKit = { 117 | Bootstrapper: function ( 118 | options: ConstructorParameters[0] 119 | ) { 120 | return new IncitoBootstrapper(config.shadow(options)); 121 | }, 122 | Viewer: IncitoViewer 123 | }; 124 | 125 | export type * from './kits/core/index'; 126 | -------------------------------------------------------------------------------- /lib/storage/client-local.ts: -------------------------------------------------------------------------------- 1 | const prefixKey = 'sgn-'; 2 | 3 | let storage: Storage; 4 | function ensureStorage() { 5 | if (storage) return; 6 | 7 | try { 8 | storage = window.localStorage; 9 | 10 | storage[prefixKey + 'test-storage'] = 'foobar'; 11 | delete storage[prefixKey + 'test-storage']; 12 | } catch (error) { 13 | storage = {} as Storage; 14 | } 15 | } 16 | 17 | export function get(key) { 18 | ensureStorage(); 19 | 20 | try { 21 | return JSON.parse(storage[prefixKey + key]); 22 | } catch (error) {} 23 | } 24 | 25 | export function set(key: string, value: any) { 26 | ensureStorage(); 27 | 28 | try { 29 | storage[prefixKey + key] = JSON.stringify(value); 30 | } catch (error) {} 31 | } 32 | 33 | export function remove(key: string) { 34 | ensureStorage(); 35 | 36 | delete storage[prefixKey + key]; 37 | } 38 | 39 | export function setWithEvent(key: string, value: any, eventName: string) { 40 | try { 41 | set(key, value); 42 | 43 | const event = new Event(eventName); 44 | 45 | window.dispatchEvent(event); 46 | } catch (error) {} 47 | } 48 | -------------------------------------------------------------------------------- /lib/stylus/buttons.styl: -------------------------------------------------------------------------------- 1 | .sgn__btn 2 | display inline-block 3 | 4 | &:hover, 5 | &:focus 6 | text-decoration none 7 | 8 | .sgn-btn--fab 9 | width 40px 10 | height 40px 11 | line-height 40px 12 | text-align center 13 | overflow hidden 14 | background-color rgba(#000, 0.2) 15 | border-radius 50% 16 | color #fff 17 | font-size 1.3em 18 | font-weight bold 19 | 20 | &:hover, 21 | &:focus 22 | background-color rgba(#000, 0.6) 23 | color #fff 24 | 25 | &:active 26 | background-color rgba(#000, 0.8) -------------------------------------------------------------------------------- /lib/stylus/sgn.styl: -------------------------------------------------------------------------------- 1 | @require './buttons' 2 | -------------------------------------------------------------------------------- /lib/tjek-sdk.ts: -------------------------------------------------------------------------------- 1 | export {default as Incito} from './incito-browser/incito'; 2 | export {default as EventTracker} from './kits/events/tracker'; 3 | export {default as IncitoPublicationBootstrapper} from './kits/incito-publication/bootstrapper'; 4 | export {default as PagedPublicationBootstrapper} from './kits/paged-publication/bootstrapper'; 5 | 6 | export type * from './kits/core/index'; 7 | -------------------------------------------------------------------------------- /lib/translations.ts: -------------------------------------------------------------------------------- 1 | import Mustache from 'mustache'; 2 | const pairs = { 3 | 'paged_publication.hotspot_picker.header': 'Which offer did you mean?', 4 | 'incito_publication.product_picker.header': 'Which product?' 5 | }; 6 | 7 | export function t(key: string, view?: any) { 8 | const template = pairs[key] ?? ''; 9 | 10 | return Mustache.render(template, view); 11 | } 12 | 13 | export function update(translations: Record) { 14 | for (const key in translations) pairs[key] = translations[key]; 15 | } 16 | -------------------------------------------------------------------------------- /lib/util.test.ts: -------------------------------------------------------------------------------- 1 | import { chunk } from './util'; 2 | 3 | describe('chunk', () => { 4 | it('should split an array into chunks of the specified size', () => { 5 | const arr = [1, 2, 3, 4, 5, 6, 7, 8, 9]; 6 | const size = 3; 7 | const result = chunk(arr, size); 8 | expect(result).toEqual([[1, 2, 3], [4, 5, 6], [7, 8, 9]]); 9 | }); 10 | 11 | it('should handle arrays that do not divide evenly by the chunk size', () => { 12 | const arr = [1, 2, 3, 4, 5, 6, 7]; 13 | const size = 3; 14 | const result = chunk(arr, size); 15 | expect(result).toEqual([[1, 2, 3], [4, 5, 6], [7]]); 16 | }); 17 | 18 | it('should return an empty array when given an empty array', () => { 19 | const arr: number[] = []; 20 | const size = 3; 21 | const result = chunk(arr, size); 22 | expect(result).toEqual([]); 23 | }); 24 | 25 | it('should handle chunk sizes larger than the array length', () => { 26 | const arr = [1, 2, 3]; 27 | const size = 5; 28 | const result = chunk(arr, size); 29 | expect(result).toEqual([[1, 2, 3]]); 30 | }); 31 | 32 | it('should handle chunk size of 1', () => { 33 | const arr = [1, 2, 3]; 34 | const size = 1; 35 | const result = chunk(arr, size); 36 | expect(result).toEqual([[1], [2], [3]]); 37 | }); 38 | 39 | it('should handle chunk size of 0', () => { 40 | const arr = [1, 2, 3]; 41 | const size = 0; 42 | const result = chunk(arr, size); 43 | expect(result).toEqual([]); 44 | }); 45 | }); -------------------------------------------------------------------------------- /lib/util.ts: -------------------------------------------------------------------------------- 1 | import Gator from '../vendor/gator'; 2 | 3 | export const isBrowser = () => 4 | typeof window === 'object' && typeof document === 'object'; 5 | 6 | export const isNode = () => typeof process === 'object'; 7 | 8 | export function error( 9 | err: Error & {code?: string; time?: Date; statusCode?: number}, 10 | options?: { 11 | message?: string; 12 | code?: string; 13 | name?: string; 14 | stack?: string; 15 | statusCode?: number; 16 | } 17 | ) { 18 | err.message = err.message; 19 | 20 | if (typeof options === 'string') { 21 | err.message = options; 22 | } else if (typeof options === 'object' && options) { 23 | for (const key in options) err[key] = options[key]; 24 | 25 | if (options.message) err.message = options.message; 26 | 27 | if (options.code || options.message) { 28 | err.code = options.code || options.name; 29 | } 30 | if (options.stack) err.stack = options.stack; 31 | 32 | if (options.statusCode) err.statusCode = options.statusCode; 33 | } 34 | 35 | err.name = options?.name || err.name || err.code || 'Error'; 36 | err.time = new Date(); 37 | 38 | return err; 39 | } 40 | 41 | export function getQueryParam(field, url?: string) { 42 | const reg = new RegExp('[?&]' + field + '=([^&#]*)', 'i'); 43 | const string = reg.exec(url || window.location.href); 44 | 45 | return string ? string[1] : undefined; 46 | } 47 | 48 | export function throttle( 49 | fn: (...args: A) => unknown, 50 | threshold = 250, 51 | scope?: object 52 | ): (...args: A) => void { 53 | let last: number | undefined; 54 | let deferTimer: NodeJS.Timeout; 55 | 56 | return function () { 57 | const context = scope || this; 58 | const now = new Date().getTime(); 59 | const args = arguments; 60 | 61 | if (last && now < last + threshold) { 62 | clearTimeout(deferTimer); 63 | 64 | deferTimer = setTimeout(() => { 65 | last = now; 66 | 67 | fn.apply(context, args); 68 | }, threshold); 69 | } else { 70 | last = now; 71 | fn.apply(context, args); 72 | } 73 | }; 74 | } 75 | 76 | export function debounce( 77 | fn: (...args: A) => unknown, 78 | threshold = 250, 79 | scope?: object 80 | ): (...args: A) => void { 81 | let deferTimer: NodeJS.Timeout; 82 | 83 | return function () { 84 | const context = scope || this; 85 | const args = arguments; 86 | 87 | if (deferTimer) clearTimeout(deferTimer); 88 | 89 | deferTimer = setTimeout(() => fn.apply(context, args), threshold); 90 | }; 91 | } 92 | 93 | export function chunk(arr: I[], size: number) { 94 | const results: I[][] = []; 95 | 96 | if (!arr.length) return results; 97 | if (size <= 0) return results; 98 | 99 | while (arr.length) results.push(arr.splice(0, size)); 100 | 101 | return results; 102 | } 103 | 104 | export const on = ( 105 | el: HTMLElement, 106 | events: string | string[], 107 | selector: string, 108 | callback: (event: any) => void 109 | ) => 110 | //@ts-expect-error 111 | Gator(el).on(events, selector, callback); 112 | 113 | export const off = ( 114 | el: HTMLElement, 115 | events?: string | string[], 116 | selector?: string, 117 | callback?: (event: any) => void 118 | ) => 119 | //@ts-expect-error 120 | Gator(el).off(events, selector, callback); 121 | -------------------------------------------------------------------------------- /lib/verso-browser/__tests__/__snapshots__/smoke.test-node.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`it can generate html with jsdom 1`] = ` 4 | "
5 |
6 |
7 |
page1
8 |
9 |
10 |
page2
11 |
12 |
13 |
page3
14 |
15 |
16 |
" 17 | `; 18 | 19 | exports[`it can generate html with jsdom 2`] = ` 20 | "
21 |
22 | 25 |
26 |
page2
27 |
28 |
29 |
page3
30 |
31 |
32 |
" 33 | `; 34 | 35 | exports[`it can generate html with jsdom 3`] = ` 36 | "
37 |
38 |
39 |
page1
40 |
41 |
42 |
page2
43 |
44 |
45 |
page3
46 |
47 |
48 |
" 49 | `; 50 | -------------------------------------------------------------------------------- /lib/verso-browser/__tests__/smoke.test-node.ts: -------------------------------------------------------------------------------- 1 | import {JSDOM} from 'jsdom'; 2 | import Verso from '../verso'; 3 | 4 | test("it doesn't break node", () => expect(true).toBeTruthy()); 5 | 6 | test('it can generate html with jsdom', () => { 7 | const {document} = new JSDOM(` 8 |
9 |
10 |
11 |
page1
12 |
13 |
18 |
page2
19 |
20 |
21 |
page3
22 |
23 |
24 |
25 | `).window; 26 | 27 | const versoEl = document.querySelector('.verso')!; 28 | const verso = new Verso(versoEl).start(); 29 | verso.navigateTo(0, {duration: 0}); 30 | expect(versoEl.outerHTML).toMatchSnapshot(); 31 | verso.navigateTo(2, {duration: 0}); 32 | expect(versoEl.outerHTML).toMatchSnapshot(); 33 | verso.navigateTo(1, {duration: 0}); 34 | expect(versoEl.outerHTML).toMatchSnapshot(); 35 | }); 36 | -------------------------------------------------------------------------------- /lib/verso-browser/__tests__/verso.test-jsdom.ts: -------------------------------------------------------------------------------- 1 | import Verso from '../verso'; 2 | let verso: Verso | null = null; 3 | 4 | beforeEach(() => { 5 | document.body.innerHTML = `\ 6 |
7 |
8 |
9 |
page1
10 |
11 |
12 |
page2
13 |
14 |
15 |
page3
16 |
17 | 18 |
19 |
\ 20 | `; 21 | 22 | verso = new Verso(document.querySelector('.verso')!).start(); 23 | }); 24 | 25 | afterEach(() => { 26 | verso!.destroy(); 27 | verso = null; 28 | }); 29 | 30 | test('Page spreads getting their active state set', () => { 31 | expect( 32 | document.querySelector('[data-id=page1]')!.dataset.active 33 | ).toBe('true'); 34 | }); 35 | 36 | test('Page spreads getting their left value set relative to each other', () => { 37 | expect( 38 | document.querySelector('[data-id=page1]')!.style.left 39 | ).toBe('0%'); 40 | expect( 41 | document.querySelector('[data-id=page2]')!.style.left 42 | ).toBe('80%'); 43 | }); 44 | 45 | test('Navigation to next page', () => { 46 | verso!.next(); 47 | 48 | expect( 49 | document.querySelector('[data-id=page1]')!.dataset.active 50 | ).toBe('false'); 51 | expect( 52 | document.querySelector('[data-id=page2]')!.dataset.active 53 | ).toBe('true'); 54 | expect( 55 | document.querySelector('[data-id=page1]')!.style.left 56 | ).toBe('0%'); 57 | expect( 58 | document.querySelector('[data-id=page2]')!.style.left 59 | ).toBe('80%'); 60 | expect( 61 | document.querySelector('[data-id=page3]')!.style.left 62 | ).toBe('180%'); 63 | }); 64 | 65 | test('Navigation to next page triggers beforeNavigation with proper data', (done) => { 66 | const beforeNavigationCallback = (data) => { 67 | expect(data).toEqual({currentPosition: 0, newPosition: 1}); 68 | return done(); 69 | }; 70 | 71 | verso!.bind('beforeNavigation', beforeNavigationCallback); 72 | return verso!.next(); 73 | }); 74 | -------------------------------------------------------------------------------- /lib/verso-browser/animation.ts: -------------------------------------------------------------------------------- 1 | export default class Animation { 2 | run = 0; 3 | el: HTMLElement; 4 | constructor(el: HTMLElement) { 5 | this.el = el; 6 | } 7 | 8 | animate( 9 | // @ts-expect-error 10 | { 11 | x = 0, 12 | y = 0, 13 | scale = 1, 14 | easing = 'ease-out', 15 | duration = 0 16 | }: { 17 | x: string | number; 18 | y?: string | number; 19 | scale?: number; 20 | easing?: string; 21 | duration?: number; 22 | } = {}, 23 | callback?: () => void 24 | ) { 25 | const run = ++this.run; 26 | const transform = `translateX(${x}) translateY(${y}) scale(${scale})`; 27 | 28 | if (this.el.style.transform === transform) { 29 | callback?.(); 30 | } else if (duration > 0) { 31 | const transitionEnd = () => { 32 | if (run !== this.run) return; 33 | 34 | this.el.removeEventListener('transitionend', transitionEnd); 35 | this.el.style.transition = 'none'; 36 | 37 | callback?.(); 38 | }; 39 | 40 | this.el.addEventListener('transitionend', transitionEnd, false); 41 | 42 | this.el.style.transition = `transform ${easing} ${duration}ms`; 43 | this.el.style.transform = transform; 44 | } else { 45 | this.el.style.transition = 'none'; 46 | this.el.style.transform = transform; 47 | 48 | callback?.(); 49 | } 50 | 51 | return this; 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /lib/verso-browser/page_spread.styl: -------------------------------------------------------------------------------- 1 | .verso__page-spread 2 | position absolute 3 | top 0 4 | left 0 5 | width 100% 6 | height 100% 7 | display none 8 | 9 | for $percentage in (1..100) 10 | &[data-width=\"{$percentage}\"] 11 | width ($percentage)% 12 | 13 | .verso__page 14 | absolute top left 15 | bottom 0 16 | width 100% 17 | transform-style preserve-3d // Ensure smooth zooming 18 | background-repeat no-repeat 19 | background-position center center 20 | 21 | .verso--scrollable 22 | overflow-y auto 23 | -webkit-overflow-scrolling touch 24 | 25 | // Position verso and recto using translateX to ensure subpixel rendering. 26 | .verso-page--verso 27 | background-position right center 28 | transform translateX(0) 29 | width 50% 30 | 31 | .verso-page--recto 32 | background-position left center 33 | transform translateX(100%) 34 | width 50% 35 | 36 | .verso__overlay 37 | absolute top left 38 | width 0% 39 | height 0% 40 | z-index 2 41 | display block 42 | cursor pointer 43 | -------------------------------------------------------------------------------- /lib/verso-browser/page_spread.ts: -------------------------------------------------------------------------------- 1 | import './page_spread.styl'; 2 | 3 | interface PageSpreadInit { 4 | id: string; 5 | type?: string; 6 | pageIds: string[]; 7 | width: number; 8 | left: number; 9 | maxZoomScale: number; 10 | } 11 | export default class PageSpread { 12 | visibility = 'gone'; 13 | positioned = false; 14 | active = false; 15 | el: HTMLElement; 16 | options: PageSpreadInit; 17 | id: string; 18 | type?: string; 19 | pageIds: string[]; 20 | width: number; 21 | left: number; 22 | maxZoomScale: number; 23 | constructor(el: HTMLElement, options: PageSpreadInit) { 24 | this.el = el; 25 | this.options = options; 26 | this.id = this.options.id; 27 | this.type = this.options.type; 28 | this.pageIds = this.options.pageIds; 29 | this.width = this.options.width; 30 | this.left = this.options.left; 31 | this.maxZoomScale = this.options.maxZoomScale; 32 | } 33 | 34 | isZoomable() { 35 | return ( 36 | this.getMaxZoomScale() > 1 && 37 | this.getEl().dataset.zoomable !== 'false' 38 | ); 39 | } 40 | 41 | isScrollable() { 42 | return this.getEl().classList.contains('verso--scrollable'); 43 | } 44 | 45 | getEl() { 46 | return this.el; 47 | } 48 | 49 | getOverlayEls() { 50 | return this.getEl().querySelectorAll('.verso__overlay'); 51 | } 52 | 53 | getPageEls() { 54 | return this.getEl().querySelectorAll('.verso__page'); 55 | } 56 | 57 | getRect() { 58 | return this.getEl().getBoundingClientRect(); 59 | } 60 | 61 | getContentRect() { 62 | const rect: { 63 | top: null | number; 64 | left: null | number; 65 | right: null | number; 66 | bottom: null | number; 67 | width: null | number; 68 | height: null | number; 69 | } = { 70 | top: null, 71 | left: null, 72 | right: null, 73 | bottom: null, 74 | width: null, 75 | height: null 76 | }; 77 | 78 | const pageEls = this.getPageEls(); 79 | for (let idx = 0; idx < pageEls.length; idx++) { 80 | const pageEl = pageEls[idx]; 81 | const pageRect = pageEl.getBoundingClientRect(); 82 | 83 | if (rect.top == null || pageRect.top < rect.top) { 84 | rect.top = pageRect.top; 85 | } 86 | if (rect.left == null || pageRect.left < rect.left) { 87 | rect.left = pageRect.left; 88 | } 89 | if (rect.right == null || pageRect.right > rect.right) { 90 | rect.right = pageRect.right; 91 | } 92 | if (rect.bottom == null || pageRect.bottom > rect.bottom) { 93 | rect.bottom = pageRect.bottom; 94 | } 95 | } 96 | 97 | rect.top = rect.top ?? 0; 98 | rect.left = rect.left ?? 0; 99 | rect.right = rect.right ?? 0; 100 | rect.bottom = rect.bottom ?? 0; 101 | rect.width = rect.right - rect.left; 102 | rect.height = rect.bottom - rect.top; 103 | 104 | return rect as { 105 | top: number; 106 | left: number; 107 | right: number; 108 | bottom: number; 109 | width: number; 110 | height: number; 111 | }; 112 | } 113 | 114 | getId() { 115 | return this.id; 116 | } 117 | 118 | getType() { 119 | return this.type; 120 | } 121 | 122 | getPageIds() { 123 | return this.pageIds; 124 | } 125 | 126 | getWidth() { 127 | return this.width; 128 | } 129 | 130 | getLeft() { 131 | return this.left; 132 | } 133 | 134 | getMaxZoomScale() { 135 | return this.maxZoomScale; 136 | } 137 | 138 | getVisibility() { 139 | return this.visibility; 140 | } 141 | 142 | setVisibility(visibility) { 143 | if (this.visibility !== visibility) { 144 | this.getEl().style.display = 145 | visibility === 'visible' ? 'block' : 'none'; 146 | 147 | this.visibility = visibility; 148 | } 149 | 150 | return this; 151 | } 152 | 153 | position() { 154 | if (!this.positioned) { 155 | this.getEl().style.left = `${this.getLeft()}%`; 156 | 157 | this.positioned = true; 158 | } 159 | 160 | return this; 161 | } 162 | 163 | activate() { 164 | this.active = true; 165 | // @ts-expect-error 166 | this.getEl().dataset.active = this.active; 167 | } 168 | 169 | deactivate() { 170 | this.active = false; 171 | // @ts-expect-error 172 | this.getEl().dataset.active = this.active; 173 | } 174 | } 175 | -------------------------------------------------------------------------------- /lib/verso-browser/vendor/hammer/TouchAction.ts: -------------------------------------------------------------------------------- 1 | import {DIRECTION_HORIZONTAL, DIRECTION_VERTICAL} from './Input'; 2 | import Manager from './Manager'; 3 | import prefixed from './utils/prefixed'; 4 | 5 | const PREFIXED_TOUCH_ACTION = 6 | typeof document !== 'undefined' && 7 | prefixed(document.createElement('div').style, 'touchAction'); 8 | 9 | // magical touchAction value 10 | export const TOUCH_ACTION_COMPUTE = 'compute'; 11 | export const TOUCH_ACTION_AUTO = 'auto'; 12 | export const TOUCH_ACTION_MANIPULATION = 'manipulation'; // not implemented 13 | export const TOUCH_ACTION_PAN_X = 'pan-x'; 14 | export const TOUCH_ACTION_PAN_Y = 'pan-y'; 15 | export const TOUCH_ACTION_NONE = 'none'; 16 | const actions = [ 17 | 'auto', 18 | 'manipulation', 19 | 'pan-y', 20 | 'pan-x', 21 | 'pan-x pan-y', 22 | 'none' 23 | ] as const; 24 | const cssSupports = typeof window !== 'undefined' && window.CSS?.supports; 25 | const TOUCH_ACTION_MAP = 26 | PREFIXED_TOUCH_ACTION && 27 | actions.reduce((touchMap, val) => { 28 | // If css.supports is not supported but there is native touch-action assume it supports 29 | // all values. This is the case for IE 10 and 11. 30 | touchMap[val] = cssSupports ? cssSupports('touch-action', val) : true; 31 | 32 | return touchMap; 33 | }, {}); 34 | 35 | function cleanTouchActions(actionsToClean: typeof actions) { 36 | // none 37 | if (actionsToClean.includes(TOUCH_ACTION_NONE)) return TOUCH_ACTION_NONE; 38 | 39 | const hasPanX = actionsToClean.includes(TOUCH_ACTION_PAN_X); 40 | const hasPanY = actionsToClean.includes(TOUCH_ACTION_PAN_Y); 41 | 42 | // if both pan-x and pan-y are set (different recognizers 43 | // for different directions, e.g. horizontal pan but vertical swipe?) 44 | // we need none (as otherwise with pan-x pan-y combined none of these 45 | // recognizers will work, since the browser would handle all panning 46 | if (hasPanX && hasPanY) return TOUCH_ACTION_NONE; 47 | 48 | // pan-x OR pan-y 49 | if (hasPanX) return TOUCH_ACTION_PAN_X; 50 | 51 | if (hasPanY) return TOUCH_ACTION_PAN_Y; 52 | 53 | // manipulation 54 | const hasManipulation = actionsToClean.includes(TOUCH_ACTION_MANIPULATION); 55 | if (hasManipulation) return TOUCH_ACTION_MANIPULATION; 56 | 57 | return TOUCH_ACTION_AUTO; 58 | } 59 | 60 | /** 61 | * Touch Action 62 | * sets the touchAction property or uses the js alternative 63 | */ 64 | export default class TouchAction { 65 | manager: Manager; 66 | actions: string; 67 | constructor(manager: Manager, value: string) { 68 | this.manager = manager; 69 | this.set(value); 70 | } 71 | 72 | /** 73 | * set the touchAction value on the element or enable the polyfill 74 | */ 75 | set(value: string) { 76 | // find out the touch-action by the event handlers 77 | if (value === TOUCH_ACTION_COMPUTE) value = this.compute(); 78 | 79 | if ( 80 | PREFIXED_TOUCH_ACTION && 81 | this.manager.element.style && 82 | TOUCH_ACTION_MAP?.[value] 83 | ) { 84 | this.manager.element.style[PREFIXED_TOUCH_ACTION] = value; 85 | } 86 | this.actions = value.toLowerCase().trim(); 87 | } 88 | 89 | /** 90 | * just re-set the touchAction value 91 | */ 92 | update() { 93 | this.set(this.manager.options.touchAction); 94 | } 95 | 96 | /** 97 | * @private 98 | * compute the value for the touchAction property based on the recognizer's settings 99 | */ 100 | compute() { 101 | return cleanTouchActions( 102 | this.manager.recognizers 103 | .reduce( 104 | (actions, recognizer) => 105 | recognizer.options.enable 106 | ? actions.concat(recognizer.getTouchAction()) 107 | : actions, 108 | [] 109 | ) 110 | .join(' ') 111 | ); 112 | } 113 | 114 | /** 115 | * this method is called on each input cycle and provides the preventing of the browser behavior 116 | */ 117 | preventDefaults({ 118 | srcEvent, 119 | pointers, 120 | distance, 121 | deltaTime, 122 | offsetDirection 123 | }) { 124 | // if the touch action did prevented once this session 125 | if (this.manager.session.prevented) return srcEvent.preventDefault(); 126 | 127 | const hasNone = 128 | this.actions.includes(TOUCH_ACTION_NONE) && 129 | !TOUCH_ACTION_MAP?.[TOUCH_ACTION_NONE]; 130 | 131 | // do not prevent defaults if this is a tap gesture 132 | if (hasNone && pointers.length === 1 && distance < 2 && deltaTime < 250) 133 | return; 134 | 135 | const hasPanY = 136 | this.actions.includes(TOUCH_ACTION_PAN_Y) && 137 | !TOUCH_ACTION_MAP?.[TOUCH_ACTION_PAN_Y]; 138 | const hasPanX = 139 | this.actions.includes(TOUCH_ACTION_PAN_X) && 140 | !TOUCH_ACTION_MAP?.[TOUCH_ACTION_PAN_X]; 141 | 142 | // `pan-x pan-y` means browser handles all scrolling/panning, do not prevent 143 | if (hasPanX && hasPanY) return; 144 | 145 | if ( 146 | hasNone || 147 | (hasPanY && offsetDirection & DIRECTION_HORIZONTAL) || 148 | (hasPanX && offsetDirection & DIRECTION_VERTICAL) 149 | ) { 150 | this.manager.session.prevented = true; 151 | srcEvent.preventDefault(); 152 | } 153 | } 154 | } 155 | -------------------------------------------------------------------------------- /lib/verso-browser/vendor/hammer/input/mouse.js: -------------------------------------------------------------------------------- 1 | import Input, { 2 | INPUT_END, 3 | INPUT_MOVE, 4 | INPUT_START, 5 | INPUT_TYPE_MOUSE 6 | } from '../Input'; 7 | 8 | const MOUSE_INPUT_MAP = { 9 | mousedown: INPUT_START, 10 | mousemove: INPUT_MOVE, 11 | mouseup: INPUT_END 12 | }; 13 | 14 | const MOUSE_ELEMENT_EVENTS = 'mousedown'; 15 | const MOUSE_WINDOW_EVENTS = 'mousemove mouseup'; 16 | 17 | /** 18 | * @private 19 | * Mouse events input 20 | * @constructor 21 | * @extends Input 22 | */ 23 | export default class MouseInput extends Input { 24 | evEl = MOUSE_ELEMENT_EVENTS; 25 | evWin = MOUSE_WINDOW_EVENTS; 26 | pressed = false; // mousedown state 27 | constructor() { 28 | super(...arguments); 29 | 30 | this.init(); 31 | } 32 | 33 | /** 34 | * @private 35 | * handle mouse events 36 | * @param {Object} ev 37 | */ 38 | handler(ev) { 39 | let eventType = MOUSE_INPUT_MAP[ev.type]; 40 | 41 | // on start we want to have the left mouse button down 42 | if (eventType & INPUT_START && ev.button === 0) this.pressed = true; 43 | 44 | if (eventType & INPUT_MOVE && ev.which !== 1) eventType = INPUT_END; 45 | 46 | // mouse must be down 47 | if (!this.pressed) return; 48 | 49 | if (eventType & INPUT_END) this.pressed = false; 50 | 51 | this.callback(this.manager, eventType, { 52 | pointers: [ev], 53 | changedPointers: [ev], 54 | pointerType: INPUT_TYPE_MOUSE, 55 | srcEvent: ev 56 | }); 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /lib/verso-browser/vendor/hammer/input/pointerevent.js: -------------------------------------------------------------------------------- 1 | import Input, { 2 | INPUT_CANCEL, 3 | INPUT_END, 4 | INPUT_MOVE, 5 | INPUT_START, 6 | INPUT_TYPE_TOUCH 7 | } from '../Input'; 8 | 9 | const POINTER_INPUT_MAP = { 10 | pointerdown: INPUT_START, 11 | pointermove: INPUT_MOVE, 12 | pointerup: INPUT_END, 13 | pointercancel: INPUT_CANCEL, 14 | pointerout: INPUT_CANCEL 15 | }; 16 | 17 | const POINTER_ELEMENT_EVENTS = 'pointerdown'; 18 | const POINTER_WINDOW_EVENTS = 'pointermove pointerup pointercancel'; 19 | 20 | /** 21 | * @private 22 | * Pointer events input 23 | * @constructor 24 | * @extends Input 25 | */ 26 | export default class PointerEventInput extends Input { 27 | evEl = POINTER_ELEMENT_EVENTS; 28 | evWin = POINTER_WINDOW_EVENTS; 29 | constructor() { 30 | super(...arguments); 31 | 32 | this.store = this.manager.session.pointerEvents = []; 33 | 34 | this.init(); 35 | } 36 | 37 | /** 38 | * @private 39 | * handle mouse events 40 | * @param {Object} ev 41 | */ 42 | handler(ev) { 43 | const {store} = this; 44 | let removePointer = false; 45 | 46 | const eventTypeNormalized = ev.type.toLowerCase().replace('ms', ''); 47 | const eventType = POINTER_INPUT_MAP[eventTypeNormalized]; 48 | const pointerType = ev.pointerType; 49 | 50 | const isTouch = pointerType === INPUT_TYPE_TOUCH; 51 | 52 | // get index of the event in the store 53 | let storeIndex = store.findIndex( 54 | ({pointerId}) => pointerId === ev.pointerId 55 | ); 56 | 57 | // start and mouse must be down 58 | if (eventType & INPUT_START && (ev.button === 0 || isTouch)) { 59 | if (storeIndex < 0) { 60 | store.push(ev); 61 | storeIndex = store.length - 1; 62 | } 63 | } else if (eventType & (INPUT_END | INPUT_CANCEL)) { 64 | removePointer = true; 65 | } 66 | 67 | // it not found, so the pointer hasn't been down (so it's probably a hover) 68 | if (storeIndex < 0) return; 69 | 70 | // update the event in the store 71 | store[storeIndex] = ev; 72 | 73 | this.callback(this.manager, eventType, { 74 | pointers: store, 75 | changedPointers: [ev], 76 | pointerType, 77 | srcEvent: ev 78 | }); 79 | 80 | // remove from the store 81 | if (removePointer) store.splice(storeIndex, 1); 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /lib/verso-browser/vendor/hammer/input/touch.js: -------------------------------------------------------------------------------- 1 | import Input, { 2 | INPUT_CANCEL, 3 | INPUT_END, 4 | INPUT_MOVE, 5 | INPUT_START, 6 | INPUT_TYPE_TOUCH, 7 | TOUCH_INPUT_MAP 8 | } from '../Input'; 9 | 10 | /** 11 | * @private 12 | * Multi-user touch events input 13 | * @constructor 14 | * @extends Input 15 | */ 16 | export default class TouchInput extends Input { 17 | evTarget = 'touchstart touchmove touchend touchcancel'; 18 | targetIds = {}; 19 | constructor() { 20 | super(...arguments); 21 | 22 | this.init(); 23 | } 24 | 25 | handler(ev) { 26 | const type = TOUCH_INPUT_MAP[ev.type]; 27 | const touches = this.getTouches(ev, type); 28 | if (!touches) return; 29 | 30 | this.callback(this.manager, type, { 31 | pointers: touches[0], 32 | changedPointers: touches[1], 33 | pointerType: INPUT_TYPE_TOUCH, 34 | srcEvent: ev 35 | }); 36 | } 37 | getTouches(ev, type) { 38 | const allTouches = Array.from(ev.touches); 39 | const {targetIds} = this; 40 | 41 | // when there is only one touch, the process can be simplified 42 | if (type & (INPUT_START | INPUT_MOVE) && allTouches.length === 1) { 43 | targetIds[allTouches[0].identifier] = true; 44 | return [allTouches, allTouches]; 45 | } 46 | 47 | // get target touches from touches 48 | const targetTouches = allTouches.filter((touch) => 49 | this.target.contains(touch.target) 50 | ); 51 | 52 | // collect touches 53 | if (type === INPUT_START) { 54 | targetTouches.forEach((targetTouch) => { 55 | targetIds[targetTouch.identifier] = true; 56 | }); 57 | } 58 | 59 | // filter changed touches to only contain touches that exist in the collected target ids 60 | const changedTargetTouches = []; 61 | Array.from(ev.changedTouches).forEach((changedTouch) => { 62 | if (targetIds[changedTouch.identifier]) { 63 | changedTargetTouches.push(changedTouch); 64 | } 65 | 66 | // cleanup removed touches 67 | if (type & (INPUT_END | INPUT_CANCEL)) { 68 | delete targetIds[changedTouch.identifier]; 69 | } 70 | }); 71 | 72 | if (!changedTargetTouches.length) return; 73 | 74 | return [ 75 | targetTouches 76 | .concat(changedTargetTouches) 77 | .filter( 78 | (item, i, list) => 79 | list.findIndex( 80 | ({identifier}) => identifier === item.identifier 81 | ) === i 82 | ), 83 | changedTargetTouches 84 | ]; 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /lib/verso-browser/vendor/hammer/input/touchmouse.js: -------------------------------------------------------------------------------- 1 | import Input, { 2 | INPUT_CANCEL, 3 | INPUT_END, 4 | INPUT_START, 5 | INPUT_TYPE_MOUSE, 6 | INPUT_TYPE_TOUCH 7 | } from '../Input'; 8 | import MouseInput from './mouse'; 9 | import TouchInput from './touch'; 10 | 11 | /** 12 | * @private 13 | * Combined touch and mouse input 14 | * 15 | * Touch has a higher priority then mouse, and while touching no mouse events are allowed. 16 | * This because touch devices also emit mouse events while doing a touch. 17 | * 18 | * @constructor 19 | * @extends Input 20 | */ 21 | 22 | const DEDUP_TIMEOUT = 2500; 23 | const DEDUP_DISTANCE = 25; 24 | 25 | export default class TouchMouseInput extends Input { 26 | primaryTouch = null; 27 | lastTouches = []; 28 | constructor() { 29 | super(...arguments); 30 | 31 | this.touch = new TouchInput(this.manager, this.handler); 32 | this.mouse = new MouseInput(this.manager, this.handler); 33 | 34 | this.init(); 35 | } 36 | 37 | /** 38 | * @private 39 | * handle mouse and touch events 40 | * @param {Hammer} manager 41 | * @param {String} inputEvent 42 | * @param {Object} inputData 43 | */ 44 | handler = (manager, inputEvent, inputData) => { 45 | const isTouch = inputData.pointerType === INPUT_TYPE_TOUCH; 46 | const isMouse = inputData.pointerType === INPUT_TYPE_MOUSE; 47 | 48 | if ( 49 | isMouse && 50 | inputData.sourceCapabilities && 51 | inputData.sourceCapabilities.firesTouchEvents 52 | ) { 53 | return; 54 | } 55 | 56 | // when we're in a touch event, record touches to de-dupe synthetic mouse event 57 | if (isTouch) { 58 | if (inputEvent & INPUT_START) { 59 | this.primaryTouch = inputData.changedPointers[0].identifier; 60 | this.setLastTouch(inputData); 61 | } else if (inputEvent & (INPUT_END | INPUT_CANCEL)) { 62 | this.setLastTouch(inputData); 63 | } 64 | } else if (isMouse && this.isSyntheticEvent(inputData)) { 65 | return; 66 | } 67 | 68 | this.callback(manager, inputEvent, inputData); 69 | }; 70 | 71 | /** 72 | * @private 73 | * remove the event listeners 74 | */ 75 | destroy() { 76 | this.touch.destroy(); 77 | this.mouse.destroy(); 78 | } 79 | 80 | isSyntheticEvent({srcEvent: {clientX, clientY}}) { 81 | return this.lastTouches.some( 82 | ({x, y}) => 83 | Math.abs(clientX - x) <= DEDUP_DISTANCE && 84 | Math.abs(clientY - y) <= DEDUP_DISTANCE 85 | ); 86 | } 87 | 88 | setLastTouch({changedPointers: [{identifier, clientX, clientY}]}) { 89 | if (identifier === this.primaryTouch) { 90 | const lastTouch = {x: clientX, y: clientY}; 91 | this.lastTouches.push(lastTouch); 92 | const lts = this.lastTouches; 93 | setTimeout(() => { 94 | const i = lts.indexOf(lastTouch); 95 | if (i > -1) lts.splice(i, 1); 96 | }, DEDUP_TIMEOUT); 97 | } 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /lib/verso-browser/vendor/hammer/recognizers/attribute.js: -------------------------------------------------------------------------------- 1 | import {INPUT_CANCEL, INPUT_END} from '../Input'; 2 | import Recognizer, { 3 | STATE_BEGAN, 4 | STATE_CANCELLED, 5 | STATE_CHANGED, 6 | STATE_ENDED, 7 | STATE_FAILED 8 | } from '../Recognizer'; 9 | 10 | /** 11 | * @private 12 | * This recognizer is just used as a base for the simple attribute recognizers. 13 | * @constructor 14 | * @extends Recognizer 15 | */ 16 | export default class AttrRecognizer extends Recognizer { 17 | /** 18 | * @private 19 | * Used to check if it the recognizer receives valid input, like input.distance > 10. 20 | * @memberof AttrRecognizer 21 | * @param {Object} input 22 | * @returns {Boolean} recognized 23 | */ 24 | attrTest(input) { 25 | const optionPointers = this.options.pointers; 26 | return optionPointers === 0 || input.pointers.length === optionPointers; 27 | } 28 | 29 | /** 30 | * @private 31 | * Process the input and return the state for the recognizer 32 | * @memberof AttrRecognizer 33 | * @param {Object} input 34 | * @returns {*} State 35 | */ 36 | process(input) { 37 | const {state} = this; 38 | 39 | const isRecognized = state & (STATE_BEGAN | STATE_CHANGED); 40 | const isValid = this.attrTest(input); 41 | 42 | // on cancel input and we've recognized before, return STATE_CANCELLED 43 | if (isRecognized && (input.eventType & INPUT_CANCEL || !isValid)) { 44 | return state | STATE_CANCELLED; 45 | } 46 | if (isRecognized || isValid) { 47 | if (input.eventType & INPUT_END) return state | STATE_ENDED; 48 | 49 | if (!(state & STATE_BEGAN)) return STATE_BEGAN; 50 | 51 | return state | STATE_CHANGED; 52 | } 53 | return STATE_FAILED; 54 | } 55 | } 56 | 57 | AttrRecognizer.prototype.defaults = { 58 | /** 59 | * @private 60 | * @type {Number} 61 | * @default 1 62 | */ 63 | pointers: 1 64 | }; 65 | -------------------------------------------------------------------------------- /lib/verso-browser/vendor/hammer/recognizers/pan.js: -------------------------------------------------------------------------------- 1 | import { 2 | directionStr, 3 | DIRECTION_ALL, 4 | DIRECTION_DOWN, 5 | DIRECTION_HORIZONTAL, 6 | DIRECTION_LEFT, 7 | DIRECTION_NONE, 8 | DIRECTION_RIGHT, 9 | DIRECTION_UP, 10 | DIRECTION_VERTICAL 11 | } from '../Input'; 12 | import {STATE_BEGAN} from '../Recognizer'; 13 | import {TOUCH_ACTION_PAN_X, TOUCH_ACTION_PAN_Y} from '../TouchAction'; 14 | import AttrRecognizer from './attribute'; 15 | 16 | /** 17 | * @private 18 | * Pan 19 | * Recognized when the pointer is down and moved in the allowed direction. 20 | * @constructor 21 | * @extends AttrRecognizer 22 | */ 23 | export default class PanRecognizer extends AttrRecognizer { 24 | pX = null; 25 | pY = null; 26 | 27 | getTouchAction() { 28 | const { 29 | options: {direction} 30 | } = this; 31 | 32 | const actions = []; 33 | if (direction & DIRECTION_HORIZONTAL) actions.push(TOUCH_ACTION_PAN_Y); 34 | if (direction & DIRECTION_VERTICAL) actions.push(TOUCH_ACTION_PAN_X); 35 | 36 | return actions; 37 | } 38 | 39 | directionTest(input) { 40 | const {options} = this; 41 | 42 | const {deltaX, deltaY} = input; 43 | let {distance} = input; 44 | 45 | // lock to axis? 46 | let hasMoved = true; 47 | if (!(input.direction & options.direction)) { 48 | if (options.direction & DIRECTION_HORIZONTAL) { 49 | input.direction = 50 | deltaX === 0 51 | ? DIRECTION_NONE 52 | : deltaX < 0 53 | ? DIRECTION_LEFT 54 | : DIRECTION_RIGHT; 55 | hasMoved = deltaX !== this.pX; 56 | distance = Math.abs(deltaX); 57 | } else { 58 | input.direction = 59 | deltaY === 0 60 | ? DIRECTION_NONE 61 | : deltaY < 0 62 | ? DIRECTION_UP 63 | : DIRECTION_DOWN; 64 | hasMoved = deltaY !== this.pY; 65 | distance = Math.abs(deltaY); 66 | } 67 | } 68 | 69 | return ( 70 | hasMoved && 71 | distance > options.threshold && 72 | input.direction & options.direction 73 | ); 74 | } 75 | 76 | attrTest(input) { 77 | return ( 78 | super.attrTest(input) && 79 | (this.state & STATE_BEGAN || 80 | (!(this.state & STATE_BEGAN) && this.directionTest(input))) 81 | ); 82 | } 83 | 84 | emit(input) { 85 | this.pX = input.deltaX; 86 | this.pY = input.deltaY; 87 | 88 | const direction = directionStr(input.direction); 89 | if (direction) input.additionalEvent = this.options.event + direction; 90 | 91 | super.emit(input); 92 | } 93 | } 94 | 95 | PanRecognizer.prototype.defaults = { 96 | event: 'pan', 97 | threshold: 10, 98 | pointers: 1, 99 | direction: DIRECTION_ALL 100 | }; 101 | -------------------------------------------------------------------------------- /lib/verso-browser/vendor/hammer/recognizers/pinch.js: -------------------------------------------------------------------------------- 1 | import {STATE_BEGAN} from '../Recognizer'; 2 | import {TOUCH_ACTION_NONE} from '../TouchAction'; 3 | import AttrRecognizer from './attribute'; 4 | 5 | /** 6 | * @private 7 | * Pinch 8 | * Recognized when two or more pointers are moving toward (zoom-in) or away from each other (zoom-out). 9 | * @constructor 10 | * @extends AttrRecognizer 11 | */ 12 | export default class PinchRecognizer extends AttrRecognizer { 13 | getTouchAction() { 14 | return [TOUCH_ACTION_NONE]; 15 | } 16 | 17 | attrTest(input) { 18 | return ( 19 | super.attrTest(input) && 20 | (Math.abs(input.scale - 1) > this.options.threshold || 21 | this.state & STATE_BEGAN) 22 | ); 23 | } 24 | 25 | emit(input) { 26 | if (input.scale !== 1) { 27 | const inOut = input.scale < 1 ? 'in' : 'out'; 28 | input.additionalEvent = this.options.event + inOut; 29 | } 30 | super.emit(input); 31 | } 32 | } 33 | 34 | PinchRecognizer.prototype.defaults = { 35 | event: 'pinch', 36 | threshold: 0, 37 | pointers: 2 38 | }; 39 | -------------------------------------------------------------------------------- /lib/verso-browser/vendor/hammer/recognizers/press.js: -------------------------------------------------------------------------------- 1 | import {INPUT_CANCEL, INPUT_END, INPUT_START} from '../Input'; 2 | import Recognizer, { 3 | STATE_FAILED, 4 | STATE_POSSIBLE, 5 | STATE_RECOGNIZED 6 | } from '../Recognizer'; 7 | import {TOUCH_ACTION_AUTO} from '../TouchAction'; 8 | 9 | /** 10 | * @private 11 | * Press 12 | * Recognized when the pointer is down for x ms without any movement. 13 | * @constructor 14 | * @extends Recognizer 15 | */ 16 | export default class PressRecognizer extends Recognizer { 17 | _timer = null; 18 | _input = null; 19 | 20 | getTouchAction() { 21 | return [TOUCH_ACTION_AUTO]; 22 | } 23 | 24 | process(input) { 25 | const {options} = this; 26 | 27 | const validPointers = input.pointers.length === options.pointers; 28 | const validMovement = input.distance < options.threshold; 29 | const validTime = input.deltaTime > options.time; 30 | 31 | this._input = input; 32 | 33 | // we only allow little movement 34 | // and we've reached an end event, so a tap is possible 35 | if ( 36 | !validMovement || 37 | !validPointers || 38 | (input.eventType & (INPUT_END | INPUT_CANCEL) && !validTime) 39 | ) { 40 | this.reset(); 41 | } else if (input.eventType & INPUT_START) { 42 | this.reset(); 43 | this._timer = setTimeout(() => { 44 | this.state = STATE_RECOGNIZED; 45 | this.tryEmit(); 46 | }, options.time); 47 | } else if (input.eventType & INPUT_END) { 48 | return STATE_RECOGNIZED; 49 | } 50 | return STATE_FAILED; 51 | } 52 | 53 | reset() { 54 | clearTimeout(this._timer); 55 | } 56 | 57 | emit(input) { 58 | if (this.state !== STATE_RECOGNIZED) return; 59 | 60 | if (input && input.eventType & INPUT_END) { 61 | this.manager.emit(`${this.options.event}up`, input); 62 | } else { 63 | this._input.timeStamp = Date.now(); 64 | this.manager.emit(this.options.event, this._input); 65 | } 66 | } 67 | /** 68 | * @private 69 | * Check that all the require failure recognizers has failed, 70 | * if true, it emits a gesture event, 71 | * otherwise, setup the state to FAILED. 72 | * @param {Object} input 73 | */ 74 | tryEmit(input) { 75 | if (this.canEmit()) return this.emit(input); 76 | 77 | // it's failing anyway 78 | this.state = STATE_FAILED; 79 | } 80 | 81 | /** 82 | * @private 83 | * can we emit? 84 | * @returns {boolean} 85 | */ 86 | canEmit() { 87 | let i = 0; 88 | while (i < this.requireFail.length) { 89 | if (!(this.requireFail[i].state & (STATE_FAILED | STATE_POSSIBLE))) 90 | return false; 91 | 92 | i++; 93 | } 94 | return true; 95 | } 96 | } 97 | 98 | PressRecognizer.prototype.defaults = { 99 | event: 'press', 100 | pointers: 1, 101 | time: 251, // minimal time of the pointer to be pressed 102 | threshold: 9 // a minimal movement is ok, but keep it low 103 | }; 104 | -------------------------------------------------------------------------------- /lib/verso-browser/vendor/hammer/recognizers/rotate.js: -------------------------------------------------------------------------------- 1 | import AttrRecognizer from './attribute'; 2 | import {TOUCH_ACTION_NONE} from '../touchactionjs/touchaction-Consts'; 3 | import {STATE_BEGAN} from '../recognizerjs/recognizer-consts'; 4 | 5 | /** 6 | * @private 7 | * Rotate 8 | * Recognized when two or more pointer are moving in a circular motion. 9 | * @constructor 10 | * @extends AttrRecognizer 11 | */ 12 | export default class RotateRecognizer extends AttrRecognizer { 13 | getTouchAction() { 14 | return [TOUCH_ACTION_NONE]; 15 | } 16 | 17 | attrTest(input) { 18 | return ( 19 | super.attrTest(input) && 20 | (Math.abs(input.rotation) > this.options.threshold || 21 | this.state & STATE_BEGAN) 22 | ); 23 | } 24 | } 25 | 26 | RotateRecognizer.prototype.defaults = { 27 | event: 'rotate', 28 | threshold: 0, 29 | pointers: 2 30 | }; 31 | -------------------------------------------------------------------------------- /lib/verso-browser/vendor/hammer/recognizers/swipe.js: -------------------------------------------------------------------------------- 1 | import AttrRecognizer from '../recognizers/attribute'; 2 | import {abs} from '../utils/utils-consts'; 3 | import { 4 | DIRECTION_HORIZONTAL, 5 | DIRECTION_VERTICAL 6 | } from '../inputjs/input-consts'; 7 | import PanRecognizer from './pan'; 8 | import {INPUT_END} from '../inputjs/input-consts'; 9 | import directionStr from '../recognizerjs/direction-str'; 10 | 11 | /** 12 | * @private 13 | * Swipe 14 | * Recognized when the pointer is moving fast (velocity), with enough distance in the allowed direction. 15 | * @constructor 16 | * @extends AttrRecognizer 17 | */ 18 | export default class SwipeRecognizer extends AttrRecognizer { 19 | getTouchAction() { 20 | return PanRecognizer.prototype.getTouchAction.call(this); 21 | } 22 | 23 | attrTest(input) { 24 | const {direction} = this.options; 25 | 26 | let velocity; 27 | if (direction & (DIRECTION_HORIZONTAL | DIRECTION_VERTICAL)) { 28 | velocity = input.overallVelocity; 29 | } else if (direction & DIRECTION_HORIZONTAL) { 30 | velocity = input.overallVelocityX; 31 | } else if (direction & DIRECTION_VERTICAL) { 32 | velocity = input.overallVelocityY; 33 | } 34 | 35 | return ( 36 | super.attrTest(input) && 37 | direction & input.offsetDirection && 38 | input.distance > this.options.threshold && 39 | input.maxPointers === this.options.pointers && 40 | abs(velocity) > this.options.velocity && 41 | input.eventType & INPUT_END 42 | ); 43 | } 44 | 45 | emit(input) { 46 | const direction = directionStr(input.offsetDirection); 47 | if (direction) this.manager.emit(this.options.event + direction, input); 48 | 49 | this.manager.emit(this.options.event, input); 50 | } 51 | } 52 | 53 | SwipeRecognizer.prototype.defaults = { 54 | event: 'swipe', 55 | threshold: 10, 56 | velocity: 0.3, 57 | direction: DIRECTION_HORIZONTAL | DIRECTION_VERTICAL, 58 | pointers: 1 59 | }; 60 | -------------------------------------------------------------------------------- /lib/verso-browser/vendor/hammer/recognizers/tap.js: -------------------------------------------------------------------------------- 1 | import {getDistance, INPUT_END, INPUT_START} from '../Input'; 2 | import Recognizer, {STATE_FAILED, STATE_RECOGNIZED} from '../Recognizer'; 3 | import {TOUCH_ACTION_MANIPULATION} from '../TouchAction'; 4 | 5 | /** 6 | * @private 7 | * A tap is recognized when the pointer is doing a small tap/click. Multiple taps are recognized if they occur 8 | * between the given interval and position. The delay option can be used to recognize multi-taps without firing 9 | * a single tap. 10 | * 11 | * The eventData from the emitted event contains the property `tapCount`, which contains the amount of 12 | * multi-taps being recognized. 13 | * @constructor 14 | * @extends Recognizer 15 | */ 16 | export default class TapRecognizer extends Recognizer { 17 | // previous time and center, 18 | // used for tap counting 19 | pTime = false; 20 | pCenter = false; 21 | _timer = null; 22 | _input = null; 23 | count = 0; 24 | 25 | getTouchAction() { 26 | return [TOUCH_ACTION_MANIPULATION]; 27 | } 28 | 29 | process(input) { 30 | const {options} = this; 31 | 32 | const validPointers = input.pointers.length === options.pointers; 33 | const validMovement = input.distance < options.threshold; 34 | const validTouchTime = input.deltaTime < options.time; 35 | 36 | this.reset(); 37 | 38 | if (input.eventType & INPUT_START && this.count === 0) { 39 | return this.failTimeout(); 40 | } 41 | 42 | // we only allow little movement 43 | // and we've reached an end event, so a tap is possible 44 | if (validMovement && validTouchTime && validPointers) { 45 | if (input.eventType !== INPUT_END) return this.failTimeout(); 46 | 47 | const validInterval = this.pTime 48 | ? input.timeStamp - this.pTime < options.interval 49 | : true; 50 | const validMultiTap = 51 | !this.pCenter || 52 | getDistance(this.pCenter, input.center) < options.posThreshold; 53 | 54 | this.pTime = input.timeStamp; 55 | this.pCenter = input.center; 56 | 57 | this.count = !validMultiTap || !validInterval ? 1 : this.count + 1; 58 | 59 | this._input = input; 60 | 61 | // if tap count matches we have recognized it, 62 | // else it has began recognizing... 63 | if (this.count % options.taps === 0) return STATE_RECOGNIZED; 64 | } 65 | return STATE_FAILED; 66 | } 67 | 68 | failTimeout() { 69 | this._timer = setTimeout(() => { 70 | this.state = STATE_FAILED; 71 | }, this.options.interval); 72 | return STATE_FAILED; 73 | } 74 | 75 | reset() { 76 | clearTimeout(this._timer); 77 | } 78 | 79 | emit() { 80 | if (this.state === STATE_RECOGNIZED) { 81 | this._input.tapCount = this.count; 82 | this.manager.emit(this.options.event, this._input); 83 | } 84 | } 85 | } 86 | 87 | TapRecognizer.prototype.defaults = { 88 | event: 'tap', 89 | pointers: 1, 90 | taps: 1, 91 | interval: 300, // max time between the multi-tap taps 92 | time: 250, // max time of the pointer to be down (like finger on the screen) 93 | threshold: 9, // a minimal movement is ok, but keep it low 94 | posThreshold: 10 // a multi-tap can be a bit off the initial position 95 | }; 96 | -------------------------------------------------------------------------------- /lib/verso-browser/vendor/hammer/utils/prefixed.ts: -------------------------------------------------------------------------------- 1 | const VENDOR_PREFIXES = ['', 'webkit', 'Moz', 'MS', 'ms', 'o'] as const; 2 | /** 3 | * get the prefixed property 4 | */ 5 | export default function prefixed(obj: CSSStyleDeclaration, property: string) { 6 | const camelProp = property[0].toUpperCase() + property.slice(1); 7 | 8 | return VENDOR_PREFIXES.find( 9 | (prefix) => (prefix ? prefix + camelProp : property) in obj 10 | ); 11 | } 12 | -------------------------------------------------------------------------------- /lib/verso-browser/verso.styl: -------------------------------------------------------------------------------- 1 | .verso 2 | position relative 3 | outline 0 4 | box-sizing border-box 5 | overflow hidden 6 | 7 | &[data-pinching="true"] 8 | .verso__page-spread:not([data-active="true"]) 9 | visibility hidden 10 | 11 | *, 12 | *:before, 13 | *:after 14 | box-sizing inherit 15 | 16 | .verso__scroller 17 | absolute top left 18 | right 0 19 | bottom 0 20 | transform-origin 0 0 21 | -------------------------------------------------------------------------------- /locales/da_DK.ts: -------------------------------------------------------------------------------- 1 | export default { 2 | locale_code: 'da_DK', 3 | publication_viewer_shopping_list_label: 'Indkøbsliste', 4 | publication_viewer_shopping_list_clear_button: 'Ryd indkøbsliste', 5 | publication_viewer_delete_crossed_out_button: 'Slet overstregede varer', 6 | publication_viewer_print_button: 'Print', 7 | publication_viewer_download_button: 'Hent', 8 | publication_viewer_until_label: 'Til og med', 9 | publication_viewer_offer_date_range: '{{{from}}} - {{{till}}}', 10 | publication_viewer_menu_date_range: '{{{from}}} - {{{till}}}', 11 | publication_viewer_expires_in_days_label: '(Udløber om {{days}} dage)', 12 | publication_viewer_valid_in_days_label: '(Gælder om {{days}} dage)', 13 | publication_viewer_expired_label: '(Udløb)', 14 | publication_viewer_pages_button: 'Sider', 15 | publication_viewer_offers_button: 'Tilbud', 16 | publication_viewer_search_text: 'Søg', 17 | publication_viewer_currency: 'DKK', 18 | publication_viewer_hotspot_picker_header: 'Hvilket tilbud mente du?', 19 | publication_viewer_overview_button: 'Oversigt', 20 | publication_viewer_close_label: 'Tilbage', 21 | publication_viewer_add_to_shopping_list: 'Tilføj til indkøbsliste', 22 | publication_viewer_visit_webshop_link: 'Besøg webshoplink', 23 | publication_viewer_upcoming: 'Kommende', 24 | publication_viewer_offer_price_from: 'Fra', 25 | publication_viewer_offer_price_for: 'for', 26 | publication_viewer_offer_valid_from: 'Gælder kun fra d. ', 27 | publication_viewer_no_product_message: 'Ingen produkt detaljer', 28 | publication_viewer_offer_increase_quantity: 'Øg antal', 29 | publication_viewer_offer_decrease_quantity: 'Mindsk antal' 30 | }; 31 | -------------------------------------------------------------------------------- /locales/en_US.ts: -------------------------------------------------------------------------------- 1 | export default { 2 | locale_code: 'en_US', 3 | publication_viewer_shopping_list_label: 'Shopping List', 4 | publication_viewer_shopping_list_clear_button: 'Clear list', 5 | publication_viewer_delete_crossed_out_button: 'Delete crossed out items', 6 | publication_viewer_print_button: 'Print', 7 | publication_viewer_download_button: 'Download PDF', 8 | publication_viewer_until_label: 'Through', 9 | publication_viewer_offer_date_range: '{{{from}}} - {{{till}}}', 10 | publication_viewer_menu_date_range: '{{{from}}} - {{{till}}}', 11 | publication_viewer_expires_in_days_label: '(Expires in {{days}} days)', 12 | publication_viewer_valid_in_days_label: '(Valid in {{days}} days)', 13 | publication_viewer_expired_label: '(Expired)', 14 | publication_viewer_pages_button: 'Pages', 15 | publication_viewer_offers_button: 'Offers', 16 | publication_viewer_search_text: 'Search', 17 | publication_viewer_currency: 'USD', 18 | publication_viewer_hotspot_picker_header: 'Which offer did you mean?', 19 | publication_viewer_overview_button: 'Overview', 20 | publication_viewer_close_label: 'Close', 21 | publication_viewer_add_to_shopping_list: 'Add to Shopping List', 22 | publication_viewer_visit_webshop_link: 'Visit Webshop Link', 23 | publication_viewer_upcoming: 'Upcoming', 24 | publication_viewer_offer_price_from: 'From', 25 | publication_viewer_offer_price_for: 'for', 26 | publication_viewer_offer_valid_from: 'Valid from ', 27 | publication_viewer_no_product_message: 'No product details', 28 | publication_viewer_offer_increase_quantity: 'Increase quantity', 29 | publication_viewer_offer_decrease_quantity: 'Decrease quantity' 30 | }; 31 | -------------------------------------------------------------------------------- /locales/index.ts: -------------------------------------------------------------------------------- 1 | export {default as da_dk} from './da_DK'; 2 | export {default as en_us} from './en_US'; 3 | export {default as sv_se} from './sv_SE'; 4 | export {default as nb_no} from './nb_NO'; 5 | -------------------------------------------------------------------------------- /locales/nb_NO.ts: -------------------------------------------------------------------------------- 1 | export default { 2 | locale_code: 'nb_NO', 3 | publication_viewer_shopping_list_label: 'Handleliste', 4 | publication_viewer_shopping_list_clear_button: 'Klar liste', 5 | publication_viewer_delete_crossed_out_button: 6 | 'Slett overstrekede elementer', 7 | publication_viewer_print_button: 'Skrive ut', 8 | publication_viewer_download_button: 'nedlasting', 9 | publication_viewer_until_label: 'Igjennom', 10 | publication_viewer_offer_date_range: '{{{from}}} - {{{till}}}', 11 | publication_viewer_menu_date_range: '{{{from}}} - {{{till}}}', 12 | publication_viewer_expires_in_days_label: '(Utløper in {{days}} dager)', 13 | publication_viewer_valid_in_days_label: '(Gyldig om {{days}} dager)', 14 | publication_viewer_expired_label: '(Utløpt)', 15 | publication_viewer_pages_button: 'Sider', 16 | publication_viewer_offers_button: 'Tilbud', 17 | publication_viewer_search_text: 'Søk', 18 | publication_viewer_currency: 'NOK', 19 | publication_viewer_hotspot_picker_header: 'Hvilket tilbud mente du?', 20 | publication_viewer_overview_button: 'oversikt', 21 | publication_viewer_close_label: 'Tilbake', 22 | publication_viewer_add_to_shopping_list: 'Legg til handleliste', 23 | publication_viewer_visit_webshop_link: 'Besøk nettbutikklink', 24 | publication_viewer_upcoming: 'Påkommende', 25 | publication_viewer_offer_price_from: 'Fra', 26 | publication_viewer_offer_price_for: 'for', 27 | publication_viewer_offer_valid_from: 'Gjelder kun fra ', 28 | publication_viewer_no_product_message: 'Ingen produktdetalje', 29 | publication_viewer_offer_increase_quantity: 'Øk antall', 30 | publication_viewer_offer_decrease_quantity: 'Minsk antall' 31 | }; 32 | -------------------------------------------------------------------------------- /locales/sv_SE.ts: -------------------------------------------------------------------------------- 1 | export default { 2 | locale_code: 'sv_SE', 3 | publication_viewer_shopping_list_label: 'Inköpslista', 4 | publication_viewer_shopping_list_clear_button: 'Rensa lista', 5 | publication_viewer_delete_crossed_out_button: 'Ta bort överstrukna objekt', 6 | publication_viewer_print_button: 'Skriva ut', 7 | publication_viewer_download_button: 'Ladda ner', 8 | publication_viewer_until_label: 'Till slut', 9 | publication_viewer_offer_date_range: '{{{from}}} - {{{till}}}', 10 | publication_viewer_menu_date_range: '{{{from}}} - {{{till}}}', 11 | publication_viewer_expires_in_days_label: '(Går ut om {{days}} dagar)', 12 | publication_viewer_valid_in_days_label: '(Gäller in {{days}} days)', 13 | publication_viewer_expired_label: '(Utgånget)', 14 | publication_viewer_pages_button: 'Sidor', 15 | publication_viewer_offers_button: 'Erbjudanden', 16 | publication_viewer_search_text: 'Sök', 17 | publication_viewer_currency: 'SEK', 18 | publication_viewer_hotspot_picker_header: 'Vilket erbjudande menade du?', 19 | publication_viewer_overview_button: 'Översikt', 20 | publication_viewer_close_label: 'Tillbaka', 21 | publication_viewer_add_to_shopping_list: 'Lägg till inköpslista', 22 | publication_viewer_visit_webshop_link: 'Besök webbshoplänk', 23 | publication_viewer_upcoming: 'Kommende', 24 | publication_viewer_offer_price_from: 'Från', 25 | publication_viewer_offer_price_for: 'för', 26 | publication_viewer_offer_valid_from: 'Gäller endast fr.o.m ', 27 | publication_viewer_no_product_message: 'Inga produktdetaljer', 28 | publication_viewer_offer_increase_quantity: 'Öka antal', 29 | publication_viewer_offer_decrease_quantity: 'Minska antal' 30 | }; 31 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "tjek-js-sdk", 3 | "description": "Tjek SDK for JavaScript.", 4 | "browserslist": "supports css-grid and supports beacon", 5 | "main": "dist/shopgun-sdk/sgn-sdk.cjs.js", 6 | "browser": "dist/shopgun-sdk/sgn-sdk.js", 7 | "module": "dist/shopgun-sdk/sgn-sdk.es.js", 8 | "jsnext:main": "dist/shopgun-sdk/sgn-sdk.es.js", 9 | "scripts": { 10 | "clean": "rimraf -r dist", 11 | "test": "run-p -l -c test:*", 12 | "test:lint": "eslint lib __tests__", 13 | "test:ci": "jest", 14 | "test:types": "tsc -noEmit", 15 | "build": "node esbuild.mjs build", 16 | "dev": "node esbuild.mjs watch", 17 | "prepublishOnly": "npm run clean && npm run build && npm run test", 18 | "publish": "GOOD=1 node publish-npm.mjs", 19 | "publish-pages": "gh-pages -d examples -e examples && gh-pages -d dist -e dist" 20 | }, 21 | "repository": { 22 | "type": "git", 23 | "url": "git+https://github.com/tjek/tjek-js-sdk.git" 24 | }, 25 | "author": "Morten Bo Rønsholdt (https://tjek.com)", 26 | "contributors": [ 27 | { 28 | "name": "Io Klarstrup", 29 | "email": "io@tjek.com" 30 | } 31 | ], 32 | "license": "ISC", 33 | "bugs": { 34 | "url": "https://github.com/tjek/tjek-js-sdk/issues" 35 | }, 36 | "homepage": "https://github.com/tjek/tjek-js-sdk#readme", 37 | "dependencies": { 38 | "cross-fetch": "^4.0.0", 39 | "md5": "^2.3.0", 40 | "mustache": "^4.2.0" 41 | }, 42 | "devDependencies": { 43 | "@aws-sdk/client-cloudfront": "^3.666.0", 44 | "@aws-sdk/client-s3": "^3.666.0", 45 | "@playwright/browser-chromium": "^1.47.2", 46 | "@types/diff": "^5.2.3", 47 | "@types/jest": "^29.5.13", 48 | "@types/mustache": "^4.2.5", 49 | "@types/nib": "^1.1.2", 50 | "@types/node": "^18.16.12", 51 | "@types/recursive-readdir": "^2.2.4", 52 | "@types/semver": "^7.5.8", 53 | "@types/stylus": "^0.48.43", 54 | "@typescript-eslint/eslint-plugin": "^8.8.1", 55 | "@typescript-eslint/parser": "^8.8.1", 56 | "app-root-path": "^3.1.0", 57 | "chalk": "^5.3.0", 58 | "diff": "^7.0.0", 59 | "esbuild": "^0.24.0", 60 | "esbuild-stylus-loader": "^0.4.3", 61 | "eslint": "^8.50.0", 62 | "eslint-config-prettier": "^9.1.0", 63 | "eslint-plugin-playwright": "^1.6.2", 64 | "file-type": "^19.5.0", 65 | "gh-pages": "^6.1.1", 66 | "glob": "^10.3.10", 67 | "inquirer": "^12.0.0", 68 | "isbinaryfile": "^5.0.2", 69 | "jest": "29.7.0", 70 | "jest-environment-jsdom": "29.7.0", 71 | "jest-esbuild": "^0.3.0", 72 | "jest-playwright-preset": "^4.0.0", 73 | "libnpm": "^3.0.1", 74 | "memfs": "^4.13.0", 75 | "nib": "^1.2.0", 76 | "npm-run-all": "^4.1.5", 77 | "ora": "^8.1.0", 78 | "playwright": "^1.47.2", 79 | "recursive-readdir": "^2.2.3", 80 | "serve": "^14.2.3", 81 | "tar": "^7.4.3", 82 | "tmp-promise": "^3.0.3", 83 | "typescript": "^5.6.2", 84 | "webpack": "^5.95.0" 85 | }, 86 | "publishConfig": { 87 | "access": "public" 88 | }, 89 | "overrides": { 90 | "jest": "$jest" 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ESNext", 4 | "allowJs": true, 5 | "allowSyntheticDefaultImports": true, 6 | "esModuleInterop": true, 7 | "resolveJsonModule": true, 8 | "moduleResolution": "node", 9 | "strictNullChecks": true, 10 | "skipLibCheck": true, 11 | // We never emit with TSC. Quells "would overwrite input file" errors. 12 | "outDir": "/dev/null" 13 | }, 14 | "include": [ 15 | "__tests__/**/*.ts", 16 | "lib/**/*.ts", 17 | "lib/**/*.js", 18 | "esbuild.mjs", 19 | "global.d.ts" 20 | ], 21 | "exclude": ["node_modules", "dist"] 22 | } 23 | -------------------------------------------------------------------------------- /vendor/microevent.ts: -------------------------------------------------------------------------------- 1 | class MicroEvent< 2 | EventMap extends Record = Record, 3 | N extends keyof EventMap = keyof EventMap 4 | > { 5 | _eventTypes: EventMap; 6 | _events: Partial void)[]>> = {}; 7 | bind( 8 | eventName: EN, 9 | callback: (...args: EventMap[EN]) => void 10 | ) { 11 | const callbacks = this._events[eventName] ?? []; 12 | this._events[eventName] = [...callbacks, callback]; 13 | } 14 | unbind( 15 | eventName: EN, 16 | callback: (...args: EventMap[EN]) => void 17 | ) { 18 | const callbacks = this._events[eventName] ?? []; 19 | this._events[eventName] = callbacks.filter((cb) => cb !== callback); 20 | } 21 | trigger(eventName: EN, ...args: EventMap[EN]) { 22 | const callbacks = this._events[eventName] ?? []; 23 | for (const callback of callbacks) callback(...args); 24 | } 25 | } 26 | 27 | // Utility for extracting event argument at second hand event consumption 28 | // i.e. EventArg => { sectionId: string; sectionPosition: number; } 29 | type TypeArg = C extends MicroEvent ? U : never; 30 | export type EventArg< 31 | C extends MicroEvent, 32 | N extends keyof TypeArg 33 | > = TypeArg[N][0]; 34 | 35 | export default MicroEvent; 36 | --------------------------------------------------------------------------------