├── .nvmrc
├── src
├── popup
│ ├── popup.scss
│ ├── popup.html
│ └── popup.tsx
├── components
│ ├── App
│ │ ├── index.ts
│ │ ├── App.scss
│ │ ├── App.tsx
│ │ └── App.test.tsx
│ ├── UserMetadata
│ │ ├── UserMetadata.scss
│ │ └── UserMetadata.tsx
│ ├── DateRangePicker
│ │ ├── DateRangePicker.scss
│ │ ├── DateRangePicker.tsx
│ │ └── DateRangePicker.test.tsx
│ ├── MainButton
│ │ ├── MainButton.scss
│ │ ├── SearchMessage.tsx
│ │ ├── MainButton.tsx
│ │ └── MainButton.test.tsx
│ ├── LoginStatus
│ │ ├── LoginStatus.scss
│ │ └── LoginStatus.tsx
│ ├── dataTestIds.ts
│ └── Consent
│ │ └── Consent.tsx
├── lib
│ ├── utils
│ │ ├── index.ts
│ │ ├── tabs.ts
│ │ ├── status.ts
│ │ ├── date.ts
│ │ └── date.test.ts
│ ├── mappers
│ │ ├── index.ts
│ │ ├── calendar-slot.mapper.ts
│ │ ├── service.mapper.ts
│ │ ├── location.mapper.ts
│ │ ├── appointment.mapper.ts
│ │ └── enriched-slot.mapper.ts
│ ├── api
│ │ ├── index.ts
│ │ ├── search-available-slots.ts
│ │ ├── common.ts
│ │ ├── search-available-dates.ts
│ │ ├── user-info.ts
│ │ ├── location-services.ts
│ │ ├── location-search.ts
│ │ ├── appointment-cancel.ts
│ │ ├── appointment-set.ts
│ │ └── prepare-visit.ts
│ ├── question-resolver
│ │ ├── question-resolver.ts
│ │ └── question-resolver.test.ts
│ ├── constants.ts
│ ├── appointment-scheduler.ts
│ ├── internal-types.ts
│ ├── visit.ts
│ ├── http.ts
│ └── locations.ts
├── assets
│ ├── gamkenbot.png
│ ├── icon-128.png
│ ├── icon-16.png
│ ├── icon-48.png
│ └── gamkenbot.svg
├── background-page
│ ├── background-page.test.ts
│ └── background-page.ts
├── hooks
│ ├── loggedIn.ts
│ └── userMetadata.ts
├── content-script
│ ├── handlers
│ │ ├── index.ts
│ │ ├── get-slot-for-calendar
│ │ │ ├── get-slot-for-calendar.ts
│ │ │ └── get-slot-for-calendar.test.ts
│ │ ├── get-service-calendar.ts
│ │ ├── scheduler.ts
│ │ └── get-service-by-location
│ │ │ ├── get-service-by-location.ts
│ │ │ └── get-service-by-location.test.ts
│ ├── priority-queue.ts
│ ├── content-script.ts
│ ├── task.ts
│ ├── gamkenbot.ts
│ └── worker.ts
├── utils
│ └── random-interval.ts
├── platform-message.ts
├── manifest.json
├── content.json
├── __mocks__
│ └── webextension-polyfill.ts
├── validators
│ ├── validators.ts
│ └── validators.test.ts
└── services
│ └── storage.ts
├── .prettierignore
├── test
├── setupTests.ts
├── driver.ts
├── overridable.ts
├── testkits
│ ├── messaging.testkit.ts
│ └── storage.testkit.ts
├── fixtures
│ ├── search-available-slots-response.fixture.ts
│ ├── location-service.fixture.ts
│ └── location-services-response.fixture.ts
├── svg-transformer.js
├── RTL
│ ├── RTLPageBaseDriver.tsx
│ └── RTLUserMetadataDriver.tsx
└── handlers.driver.ts
├── .github
├── FUNDING.yml
├── workflows
│ ├── tests.yml
│ └── pr-checks.yaml
├── ISSUE_TEMPLATE.md
└── PULL_REQUEST_TEMPLATE.md
├── .gitignore
├── typings.d.ts
├── babel.config.js
├── .prettierrc.js
├── .devcontainer
├── post-install.sh
└── devcontainer.json
├── webpack.dev.js
├── tsconfig.json
├── webpack.prod.js
├── LICENSE
├── .eslintrc.js
├── webpack.common.js
├── CONTRIBUTING.md
├── CODE_OF_CONDUCT.md
├── package.json
├── README.md
└── jest.config.js
/.nvmrc:
--------------------------------------------------------------------------------
1 | 16.13.0
2 |
--------------------------------------------------------------------------------
/src/popup/popup.scss:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/.prettierignore:
--------------------------------------------------------------------------------
1 | node_modules/*
2 | *.snap
--------------------------------------------------------------------------------
/test/setupTests.ts:
--------------------------------------------------------------------------------
1 | import '@testing-library/jest-dom';
2 |
--------------------------------------------------------------------------------
/src/components/App/index.ts:
--------------------------------------------------------------------------------
1 | export { App } from './App';
2 |
--------------------------------------------------------------------------------
/src/lib/utils/index.ts:
--------------------------------------------------------------------------------
1 | export * from './date';
2 | export * from './tabs';
3 |
--------------------------------------------------------------------------------
/.github/FUNDING.yml:
--------------------------------------------------------------------------------
1 | # These are supported funding model platforms
2 | github: shilomagen
3 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | /node_modules
2 | npm-debug.log
3 | yarn-error.log
4 |
5 | dist
6 |
7 | .DS_Store
8 | .idea
9 |
--------------------------------------------------------------------------------
/src/assets/gamkenbot.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/shilomagen/passport-extension/HEAD/src/assets/gamkenbot.png
--------------------------------------------------------------------------------
/src/assets/icon-128.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/shilomagen/passport-extension/HEAD/src/assets/icon-128.png
--------------------------------------------------------------------------------
/src/assets/icon-16.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/shilomagen/passport-extension/HEAD/src/assets/icon-16.png
--------------------------------------------------------------------------------
/src/assets/icon-48.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/shilomagen/passport-extension/HEAD/src/assets/icon-48.png
--------------------------------------------------------------------------------
/typings.d.ts:
--------------------------------------------------------------------------------
1 | declare module '*.scss';
2 | declare module '*.png';
3 | declare module '*.svg';
4 | declare module '*.gif';
5 |
--------------------------------------------------------------------------------
/babel.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | presets: [['@babel/preset-env', { targets: { node: 'current' } }], '@babel/preset-typescript'],
3 | };
4 |
--------------------------------------------------------------------------------
/.prettierrc.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | semi: true,
3 | trailingComma: 'all',
4 | singleQuote: true,
5 | tabWidth: 2,
6 | printWidth: 120,
7 | };
8 |
--------------------------------------------------------------------------------
/src/components/UserMetadata/UserMetadata.scss:
--------------------------------------------------------------------------------
1 |
2 | .inputContainer {
3 | margin-bottom: 20px;
4 | }
5 |
6 | .selectContainer {
7 | margin-bottom: 20px;
8 | width: 100%;
9 | }
--------------------------------------------------------------------------------
/src/lib/mappers/index.ts:
--------------------------------------------------------------------------------
1 | export * from './calendar-slot.mapper';
2 | export * from './appointment.mapper';
3 | export * from './enriched-slot.mapper';
4 | export * from './location.mapper';
5 | export * from './service.mapper';
6 |
--------------------------------------------------------------------------------
/src/components/DateRangePicker/DateRangePicker.scss:
--------------------------------------------------------------------------------
1 | .container {
2 | display: flex;
3 | gap: 10px;
4 | }
5 |
6 | .dateContainer {
7 | flex: 50%;
8 | }
9 |
10 | .datePickerContainer {
11 | margin-bottom: 20px;
12 | width: 230px;
13 | }
14 |
15 |
--------------------------------------------------------------------------------
/test/driver.ts:
--------------------------------------------------------------------------------
1 | import { StorageService } from '@src/services/storage';
2 |
3 | const storageService = new StorageService();
4 |
5 | export class TestsDriver {
6 | given = {
7 | userId: (userId = '1234') => storageService.setUserId(userId),
8 | };
9 | }
10 |
--------------------------------------------------------------------------------
/src/background-page/background-page.test.ts:
--------------------------------------------------------------------------------
1 | import './background-page';
2 | import { TestsDriver } from '../../test/driver';
3 |
4 | const driver = new TestsDriver();
5 |
6 | describe('[Background Page]', () => {
7 | it('should work', () => {
8 | expect(true).toBe(true);
9 | });
10 | });
11 |
--------------------------------------------------------------------------------
/src/components/MainButton/MainButton.scss:
--------------------------------------------------------------------------------
1 | .buttonContainer {
2 | display: flex;
3 | justify-content: center;
4 | }
5 |
6 | .messageContainer {
7 | display: flex;
8 | justify-content: center;
9 | }
10 |
11 | .warning {
12 | color: #f0ad4e
13 | }
14 |
15 | .error {
16 | color: #d9534f
17 | }
--------------------------------------------------------------------------------
/.devcontainer/post-install.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 | echo "[ + ] Preparing environment..."
3 | echo "[ + ] Installing dependencies..."
4 | npm install;
5 | clear;
6 | echo "[ + ] Done!"
7 | echo "[ + ] Running tests..."
8 | npm test; clear;
9 | echo "[ + ] Done!"
10 | npm run build;
11 | echo "[ + ] Done!"
12 |
13 |
--------------------------------------------------------------------------------
/src/popup/popup.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | תור לחידוש דרכון
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
--------------------------------------------------------------------------------
/src/lib/api/index.ts:
--------------------------------------------------------------------------------
1 | export * from './appointment-set';
2 | export * from './appointment-cancel';
3 | export * from './common';
4 | export * from './location-search';
5 | export * from './location-services';
6 | export * from './prepare-visit';
7 | export * from './search-available-dates';
8 | export * from './search-available-slots';
9 |
--------------------------------------------------------------------------------
/src/lib/mappers/calendar-slot.mapper.ts:
--------------------------------------------------------------------------------
1 | import { CalendarSlot, EnrichedService } from '../internal-types';
2 |
3 | export function toCalendarSlot({ calendarDate, serviceId }: EnrichedService, timeSinceMidnight: number): CalendarSlot {
4 | return {
5 | date: calendarDate,
6 | timeSinceMidnight,
7 | serviceId,
8 | };
9 | }
10 |
--------------------------------------------------------------------------------
/src/lib/utils/tabs.ts:
--------------------------------------------------------------------------------
1 | import browser, { Tabs } from 'webextension-polyfill';
2 | import Tab = Tabs.Tab;
3 |
4 | export const getMyVisitTab = async (): Promise => {
5 | const [tab] = await browser.tabs.query({
6 | active: true,
7 | currentWindow: true,
8 | url: '*://*.myvisit.com/*',
9 | });
10 | return tab;
11 | };
12 |
--------------------------------------------------------------------------------
/test/overridable.ts:
--------------------------------------------------------------------------------
1 | import merge from 'lodash.merge';
2 | import { DeepPartial } from 'ts-essentials';
3 |
4 | export type Overridable = (overrides?: DeepPartial) => T;
5 |
6 | export const overridable =
7 | (defaults: () => T): Overridable =>
8 | (overrides: DeepPartial = {} as DeepPartial): T =>
9 | merge(defaults(), overrides);
10 |
--------------------------------------------------------------------------------
/src/lib/mappers/service.mapper.ts:
--------------------------------------------------------------------------------
1 | import { Service } from '../internal-types';
2 | import { LocationServicesResult } from '../api';
3 |
4 | export function toService(result: LocationServicesResult): Service {
5 | return {
6 | id: result.serviceId,
7 | description: result.description,
8 | name: result.serviceName,
9 | locationId: result.LocationId,
10 | };
11 | }
12 |
--------------------------------------------------------------------------------
/src/lib/mappers/location.mapper.ts:
--------------------------------------------------------------------------------
1 | import { LocationSearchResult } from '../api';
2 | import { Location } from '../internal-types';
3 |
4 | export function toLocation(result: LocationSearchResult): Location {
5 | return {
6 | id: result.LocationId,
7 | city: result.City,
8 | address: `${result.Address1} ${result.Address2}`,
9 | description: result.Description,
10 | name: result.LocationName,
11 | };
12 | }
13 |
--------------------------------------------------------------------------------
/src/lib/api/search-available-slots.ts:
--------------------------------------------------------------------------------
1 | import { CommonResultsResponse } from './common';
2 |
3 | export interface SearchAvailableSlotsRequest {
4 | CalendarId: number;
5 | ServiceId: number;
6 | dayPart: number;
7 | }
8 |
9 | export interface SearchAvailableSlotsResult {
10 | Time: number; // minutes since midnight
11 | }
12 |
13 | export type SearchAvailableSlotsResponse = CommonResultsResponse;
14 |
--------------------------------------------------------------------------------
/src/lib/api/common.ts:
--------------------------------------------------------------------------------
1 | interface CommonResponse {
2 | Success: boolean;
3 | Page: number;
4 | ResultsPerPage: number;
5 | TotalResults: number;
6 | ErrorMessage?: any;
7 | ErrorNumber: number;
8 | Messages?: any;
9 | }
10 |
11 | export interface CommonResultsResponse extends CommonResponse {
12 | Results: T | null;
13 | }
14 |
15 | export interface CommonDataResponse extends CommonResponse {
16 | Data: T;
17 | }
18 |
--------------------------------------------------------------------------------
/src/lib/api/search-available-dates.ts:
--------------------------------------------------------------------------------
1 | import { CommonResultsResponse } from './common';
2 |
3 | export interface SearchAvailableDatesRequest {
4 | maxResults?: number;
5 | serviceId: number;
6 | startDate: string; //YYYY-MM-DD
7 | }
8 |
9 | export interface SearchAvailableDatesResult {
10 | calendarDate: string;
11 | calendarId: number;
12 | }
13 | export type SearchAvailableDatesResponse = CommonResultsResponse;
14 |
--------------------------------------------------------------------------------
/src/lib/mappers/appointment.mapper.ts:
--------------------------------------------------------------------------------
1 | import { Appointment, EnrichedSlot } from '../internal-types';
2 | import { DateUtils } from '../utils';
3 |
4 | export function toAppointment(slot: EnrichedSlot): Appointment {
5 | return {
6 | date: DateUtils.toIsraelFormattedDate(slot.date),
7 | hour: DateUtils.timeSinceMidnightToHour(slot.timeSinceMidnight),
8 | city: slot.city,
9 | address: slot.address,
10 | branchName: slot.branchName,
11 | };
12 | }
13 |
--------------------------------------------------------------------------------
/src/components/LoginStatus/LoginStatus.scss:
--------------------------------------------------------------------------------
1 | .container {
2 | position: absolute;
3 | top: 20px;
4 | left: 20px;
5 | display: flex;
6 | align-items: center;
7 | }
8 |
9 | .circle {
10 | width: 15px;
11 | height: 15px;
12 | border-radius: 50%;
13 | background-color: #ccc;
14 | display: inline-block;
15 | margin-left: 5px;
16 | }
17 |
18 | .connected {
19 | background-color: #a7c957;
20 |
21 | }
22 |
23 | .disconnected {
24 | background-color: #D00000;
25 | }
26 |
--------------------------------------------------------------------------------
/src/components/dataTestIds.ts:
--------------------------------------------------------------------------------
1 | export const UserMetadataTestIds = {
2 | ID: 'user-metadata-id',
3 | CITIES: 'user-metadata-cities',
4 | PHONE_NUMBER: 'user-metadata-phone-number',
5 | };
6 |
7 | export const DatePickerTestIds = {
8 | START_DATE_PICKER: 'start-date-picker',
9 | END_DATE_PICKER: 'end-date-picker',
10 | };
11 |
12 | export const AppTestIds = {
13 | CONSENT_CHECKBOX: 'consent-checkbox',
14 | START_SEARCH_BUTTON: 'start-search-button',
15 | STOP_SEARCH_BUTTON: 'stop-search-button',
16 | };
17 |
--------------------------------------------------------------------------------
/src/lib/mappers/enriched-slot.mapper.ts:
--------------------------------------------------------------------------------
1 | import { CalendarSlot, EnrichedSlot, Location } from '../internal-types';
2 | import { City } from '../constants';
3 |
4 | export function toEnrichedSlot(calendarSlot: CalendarSlot, location: Location): EnrichedSlot {
5 | return {
6 | serviceId: calendarSlot.serviceId,
7 | timeSinceMidnight: calendarSlot.timeSinceMidnight,
8 | date: calendarSlot.date,
9 | address: location.address,
10 | branchName: location.name,
11 | city: location.city,
12 | };
13 | }
14 |
--------------------------------------------------------------------------------
/src/components/App/App.scss:
--------------------------------------------------------------------------------
1 | .container {
2 | position: relative;
3 | min-height: 450px;
4 | padding: 20px;
5 | min-width: 500px;
6 | display: flex;
7 | flex-direction: column;
8 | }
9 |
10 |
11 |
12 |
13 | .searchingGif {
14 | width: 46px;
15 | height: 46px;
16 | }
17 |
18 | .logoContainer {
19 | display: flex;
20 | justify-content: center;
21 | margin-bottom: 20px;
22 | }
23 | .logo {
24 | width: 120px;
25 | height: 120px;
26 | }
27 |
28 | .consentContainer {
29 | margin-bottom: 20px;
30 | }
31 |
--------------------------------------------------------------------------------
/src/hooks/loggedIn.ts:
--------------------------------------------------------------------------------
1 | import { useEffect, useState } from 'react';
2 | import { StorageService } from '@src/services/storage';
3 |
4 | const storageService = new StorageService();
5 |
6 | export const useLoggedIn = (): boolean => {
7 | const [loggedIn, setLoggedIn] = useState(false);
8 |
9 | useEffect(() => {
10 | storageService.getLoggedIn().then(setLoggedIn);
11 | const unsubscribe = storageService.onLoggedInChange(setLoggedIn);
12 |
13 | return () => {
14 | unsubscribe();
15 | };
16 | }, []);
17 |
18 | return loggedIn;
19 | };
20 |
--------------------------------------------------------------------------------
/src/lib/utils/status.ts:
--------------------------------------------------------------------------------
1 | import browser from 'webextension-polyfill';
2 | import { SearchStatus } from '@src/lib/internal-types';
3 | import { ActionTypes, PlatformMessage } from '@src/platform-message';
4 | import { StorageService } from '@src/services/storage';
5 |
6 | const storageService = new StorageService();
7 |
8 | export const dispatchSearchStatus = async (status: SearchStatus): Promise => {
9 | await storageService.setSearchStatus(status);
10 | await browser.runtime.sendMessage({ action: ActionTypes.SetSearchStatus, status } as PlatformMessage);
11 | };
12 |
--------------------------------------------------------------------------------
/webpack.dev.js:
--------------------------------------------------------------------------------
1 | const { merge } = require('webpack-merge');
2 | const common = require('./webpack.common.js');
3 |
4 | module.exports = merge(common, {
5 | mode: 'development',
6 | devtool: 'inline-source-map',
7 | module: {
8 | rules: [
9 | {
10 | test: /\.(sa|sc|c)ss$/i,
11 | use: [
12 | 'style-loader',
13 | {
14 | loader: 'css-loader',
15 | options: { modules: true },
16 | },
17 | 'postcss-loader',
18 | 'sass-loader',
19 | ],
20 | },
21 | ],
22 | },
23 | });
24 |
--------------------------------------------------------------------------------
/test/testkits/messaging.testkit.ts:
--------------------------------------------------------------------------------
1 | export class MessagingTestkit {
2 | private readonly listeners: ((...args: any[]) => void)[] = [];
3 |
4 | sendMessage = async (message: any): Promise => {
5 | for (let i = 0; i < this.listeners.length; i++) {
6 | await this.listeners[i](message);
7 | }
8 | };
9 |
10 | addListener = (listener: (...args: any[]) => void): void => {
11 | this.listeners.push(listener);
12 | };
13 |
14 | removeListener = (listener: (...args: any[]) => void): void => {
15 | this.listeners.splice(this.listeners.indexOf(listener), 1);
16 | };
17 | }
18 |
--------------------------------------------------------------------------------
/test/fixtures/search-available-slots-response.fixture.ts:
--------------------------------------------------------------------------------
1 | import { overridable } from '../overridable';
2 | import { SearchAvailableSlotsResponse, SearchAvailableSlotsResult } from '@src/lib/api';
3 |
4 | const defaultResponse = (): SearchAvailableSlotsResponse => {
5 | return {
6 | Success: true,
7 | Results: [{ Time: 528 }],
8 | Page: 0,
9 | ResultsPerPage: 0,
10 | TotalResults: 18,
11 | ErrorMessage: null,
12 | ErrorNumber: 0,
13 | Messages: [],
14 | };
15 | };
16 |
17 | export const SearchAvailableSlotsResponseFixtures = {
18 | valid: overridable(() => defaultResponse()),
19 | };
20 |
--------------------------------------------------------------------------------
/src/lib/api/user-info.ts:
--------------------------------------------------------------------------------
1 | import { CommonResultsResponse } from '@src/lib/api/common';
2 |
3 | export interface User {
4 | username: string;
5 | emaiAddress: string; // That's not a typo, it's how they spelled it
6 | isAnonymous: boolean;
7 | isExternalLogin: boolean;
8 | hasSingleActiveVisitToday: boolean;
9 | hasMultipleVisits: boolean;
10 | visitsCount: number;
11 | hasActiveVisits: boolean;
12 | visitId: number;
13 | smsNotificationsEnabled: boolean;
14 | smsVerified: boolean;
15 | phoneMask: string;
16 | token: string;
17 | }
18 |
19 | export type GetUserInfoResponse = CommonResultsResponse;
20 |
--------------------------------------------------------------------------------
/src/content-script/handlers/index.ts:
--------------------------------------------------------------------------------
1 | import { PriorityQueue } from '@src/content-script/priority-queue';
2 | import { BaseTask } from '@src/content-script/task';
3 | import { HttpService } from '@src/lib/http';
4 | import { StorageService } from '@src/services/storage';
5 |
6 | export interface BaseParams {
7 | priorityQueue: PriorityQueue;
8 | slotsQueue: PriorityQueue;
9 | httpService: HttpService;
10 | storage: StorageService;
11 | }
12 |
13 | export abstract class BaseHandler {
14 | constructor(protected readonly params: BaseParams) {}
15 |
16 | abstract handle(task: T): Promise;
17 | }
18 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "baseUrl": "./",
4 | "paths": {
5 | "@src/*": [
6 | "src/*"
7 | ],
8 | "@test/*": [
9 | "test/*"
10 | ],
11 | },
12 | "target": "es5",
13 | "module": "commonjs",
14 | "moduleResolution": "node",
15 | "resolveJsonModule": true,
16 | "outDir": "dist/js",
17 | "strict": true,
18 | "sourceMap": true,
19 | "jsx": "react",
20 | "esModuleInterop": true,
21 | "lib": [
22 | "ESNext",
23 | "dom"
24 | ],
25 | "experimentalDecorators": true,
26 | "allowSyntheticDefaultImports": true
27 | }
28 | }
29 |
--------------------------------------------------------------------------------
/webpack.prod.js:
--------------------------------------------------------------------------------
1 | const { merge } = require('webpack-merge');
2 | const common = require('./webpack.common.js');
3 | const MiniCssExtractPlugin = require('mini-css-extract-plugin');
4 |
5 | module.exports = merge(common, {
6 | mode: 'production',
7 | module: {
8 | rules: [
9 | {
10 | test: /\.(sa|sc|c)ss$/i,
11 | use: [
12 | MiniCssExtractPlugin.loader,
13 | {
14 | loader: 'css-loader',
15 | options: { modules: true },
16 | },
17 | 'postcss-loader',
18 | 'sass-loader',
19 | ],
20 | },
21 | ],
22 | },
23 | plugins: [new MiniCssExtractPlugin()],
24 | });
25 |
--------------------------------------------------------------------------------
/src/content-script/priority-queue.ts:
--------------------------------------------------------------------------------
1 | import { MaxPriorityQueue } from '@datastructures-js/priority-queue';
2 | import { Task } from '@src/content-script/task';
3 |
4 | export class PriorityQueue {
5 | private readonly queue: MaxPriorityQueue = new MaxPriorityQueue((task) => task.priority);
6 |
7 | clear() {
8 | return this.queue.clear();
9 | }
10 |
11 | isEmpty() {
12 | return this.queue.isEmpty();
13 | }
14 |
15 | enqueue(task: Task) {
16 | this.queue.enqueue(task);
17 | }
18 |
19 | dequeue(): Task {
20 | return this.queue.dequeue();
21 | }
22 |
23 | toArray(): Task[] {
24 | return this.queue.toArray();
25 | }
26 | }
27 |
--------------------------------------------------------------------------------
/src/content-script/content-script.ts:
--------------------------------------------------------------------------------
1 | import browser from 'webextension-polyfill';
2 | import { ActionTypes, PlatformMessage } from '@src/platform-message';
3 | import { Gamkenbot } from '@src/content-script/gamkenbot';
4 | import { match } from 'ts-pattern';
5 |
6 | const gamkenbot = new Gamkenbot();
7 |
8 | browser.runtime.onMessage.addListener((message: PlatformMessage) => {
9 | match(message)
10 | .with({ action: ActionTypes.IsLoggedIn }, gamkenbot.setLoggedIn)
11 | .with({ action: ActionTypes.StartSearch }, gamkenbot.startSearching)
12 | .with({ action: ActionTypes.StopSearch }, gamkenbot.stopSearching)
13 | .with({ action: ActionTypes.SetSearchStatus }, () => null)
14 | .exhaustive();
15 | });
16 |
--------------------------------------------------------------------------------
/src/components/LoginStatus/LoginStatus.tsx:
--------------------------------------------------------------------------------
1 | import React, { FunctionComponent } from 'react';
2 | import { Typography } from 'antd';
3 | import { statuses as Content } from '@src/content.json';
4 | import styles from './LoginStatus.scss';
5 | import { useLoggedIn } from '@src/hooks/loggedIn';
6 |
7 | const { Text } = Typography;
8 |
9 | export const LoginStatus: FunctionComponent = () => {
10 | const loggedIn = useLoggedIn();
11 | const circleStyle = [styles.circle, loggedIn ? styles.connected : styles.disconnected].join(' ');
12 | return (
13 |
14 |
15 |
{loggedIn ? Content.connected : Content.disconnected}
16 |
17 | );
18 | };
19 |
--------------------------------------------------------------------------------
/src/utils/random-interval.ts:
--------------------------------------------------------------------------------
1 | const random = (min: number, max: number): number => Math.floor(Math.random() * (max - min + 1)) + min;
2 |
3 | export interface RandomIntervalClear {
4 | clear: () => void;
5 | }
6 |
7 | const setRandomInterval = (intervalFunction: () => void, minDelay = 0, maxDelay = 0): RandomIntervalClear => {
8 | let timeout: ReturnType;
9 |
10 | const runInterval = (): void => {
11 | timeout = setTimeout(() => {
12 | intervalFunction();
13 | runInterval();
14 | }, random(minDelay, maxDelay));
15 | };
16 |
17 | runInterval();
18 |
19 | return {
20 | clear(): void {
21 | clearTimeout(timeout);
22 | },
23 | };
24 | };
25 |
26 | export default setRandomInterval;
27 |
--------------------------------------------------------------------------------
/test/testkits/storage.testkit.ts:
--------------------------------------------------------------------------------
1 | export class LocalStorageTestkit {
2 | private storage: Record = {};
3 |
4 | clear(): void {
5 | this.storage = {};
6 | }
7 | async get(key: string): Promise {
8 | const keyValue = this.storage[key];
9 | return keyValue
10 | ? Promise.resolve({
11 | [key]: keyValue,
12 | })
13 | : Promise.resolve({});
14 | }
15 |
16 | remove(key: string): Promise {
17 | delete this.storage[key];
18 | return Promise.resolve();
19 | }
20 |
21 | async set(value: Record): Promise {
22 | const key = Object.keys(value)[0];
23 | this.storage = {
24 | ...this.storage,
25 | [key]: value[key],
26 | };
27 | }
28 | }
29 |
--------------------------------------------------------------------------------
/src/platform-message.ts:
--------------------------------------------------------------------------------
1 | import { SearchStatus } from './lib/internal-types';
2 |
3 | export enum ActionTypes {
4 | IsLoggedIn = 'IS_LOGGED_IN',
5 | StartSearch = 'START_SEARCH',
6 | StopSearch = 'STOP_SEARCH',
7 | SetSearchStatus = 'SET_SEARCH_STATUS',
8 | }
9 |
10 | interface IsLoggedInMessage {
11 | action: ActionTypes.IsLoggedIn;
12 | }
13 |
14 | interface StartSearchMessage {
15 | action: ActionTypes.StartSearch;
16 | }
17 |
18 | interface StopSearchMessage {
19 | action: ActionTypes.StopSearch;
20 | }
21 |
22 | interface SearchStatusMessage {
23 | action: ActionTypes.SetSearchStatus;
24 | status: SearchStatus;
25 | }
26 |
27 | export type PlatformMessage = IsLoggedInMessage | StartSearchMessage | StopSearchMessage | SearchStatusMessage;
28 |
--------------------------------------------------------------------------------
/test/fixtures/location-service.fixture.ts:
--------------------------------------------------------------------------------
1 | import { overridable } from '../overridable';
2 | import { LocationServicesResult } from '@src/lib/api';
3 |
4 | const defaultService = (): LocationServicesResult => {
5 | return {
6 | serviceId: 2095,
7 | serviceName: 'תיאום פגישה לתיעוד ביומטרי',
8 | serviceDescription: '',
9 | ServiceTypeId: 156,
10 | serviceTypeDescription: '',
11 | description: 'בכל פנייה יינתן שירות לפונה/אדם אחד בלבד',
12 | showStats: false,
13 | waitingTime: 0,
14 | HasCalendarService: true,
15 | DynamicFormsEnabled: true,
16 | HasFIFOService: false,
17 | ExtRef: '',
18 | LocationId: 799,
19 | };
20 | };
21 |
22 | export const LocationServicesResultFixtures = {
23 | valid: overridable(() => defaultService()),
24 | };
25 |
--------------------------------------------------------------------------------
/src/popup/popup.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import { createRoot } from 'react-dom/client';
3 | import browser from 'webextension-polyfill';
4 | import { App } from '@src/components/App';
5 | import { ConfigProvider } from 'antd';
6 | import locale from 'antd/locale/he_IL';
7 |
8 | import 'antd/dist/reset.css';
9 | import './popup.scss';
10 |
11 | export const Popup = () => (
12 |
13 |
14 |
15 | );
16 | browser.tabs.query({ active: true, currentWindow: true }).then(() => {
17 | const root = createRoot(document.getElementById('popup') as HTMLElement);
18 |
19 | root.render(
20 |
21 |
22 | ,
23 | );
24 | });
25 |
--------------------------------------------------------------------------------
/src/components/MainButton/SearchMessage.tsx:
--------------------------------------------------------------------------------
1 | import React, { FC } from 'react';
2 | import { Typography } from 'antd';
3 | import { SearchStatus, SearchStatusType } from '@src/lib/internal-types';
4 | import styles from './MainButton.scss';
5 |
6 | const { Text } = Typography;
7 |
8 | interface SearchMessageProps {
9 | searchStatus: SearchStatus;
10 | }
11 |
12 | export const SearchMessage: FC = ({ searchStatus }) => {
13 | const colorClass =
14 | searchStatus.type === SearchStatusType.Error
15 | ? styles.error
16 | : searchStatus.type === SearchStatusType.Warning
17 | ? styles.warning
18 | : '';
19 |
20 | return (
21 |
22 | {searchStatus.message}
23 |
24 | );
25 | };
26 |
--------------------------------------------------------------------------------
/src/lib/api/location-services.ts:
--------------------------------------------------------------------------------
1 | import { CommonResultsResponse } from './common';
2 |
3 | export interface LocationServicesRequest {
4 | currentPage?: number;
5 | isFavorite?: boolean;
6 | orderBy?: string;
7 | locationId: number;
8 | resultsInPage?: number;
9 | serviceTypeId?: number;
10 | }
11 |
12 | export interface LocationServicesResult {
13 | serviceId: number;
14 | serviceName: string;
15 | serviceDescription: string;
16 | ServiceTypeId: number;
17 | serviceTypeDescription: string;
18 | description: string;
19 | showStats: boolean;
20 | waitingTime: number;
21 | HasCalendarService: boolean;
22 | DynamicFormsEnabled: boolean;
23 | HasFIFOService: boolean;
24 | ExtRef: string;
25 | LocationId: number;
26 | }
27 | export type LocationServicesResponse = CommonResultsResponse;
28 |
--------------------------------------------------------------------------------
/test/svg-transformer.js:
--------------------------------------------------------------------------------
1 | const path = require('path');
2 |
3 | function escapeFileName(str) {
4 | return `svg-${path.basename(str, '.svg')}`
5 | .split(/\W+/)
6 | .map((x) => `${x.charAt(0).toUpperCase()}${x.slice(1)}`)
7 | .join('');
8 | }
9 |
10 | const transform = (src, filePath) => {
11 | if (path.extname(filePath) !== '.svg') {
12 | return src;
13 | }
14 |
15 | const name = escapeFileName(filePath);
16 | return {
17 | code: `
18 | const React = require('react');
19 | function ${name}(props) {
20 | return React.createElement(
21 | 'svg',
22 | Object.assign({}, props, {'data-file-name': ${name}.name})
23 | );
24 | }
25 | module.exports = ${name};
26 | `,
27 | };
28 | };
29 |
30 | module.exports = {
31 | process: transform,
32 | };
33 |
--------------------------------------------------------------------------------
/src/manifest.json:
--------------------------------------------------------------------------------
1 | {
2 | "manifest_version": 3,
3 | "name": "Gamkenbot",
4 | "description": "Gamkenbot will help you get an appointment at the Ministry of Interior",
5 | "version": "1.1.5",
6 | "action": {
7 | "default_icon": {
8 | "16": "assets/icon-16.png",
9 | "48": "assets/icon-48.png",
10 | "128": "assets/icon-128.png"
11 | },
12 | "default_popup": "popup.html"
13 | },
14 | "background": {
15 | "service_worker": "background-page.js"
16 | },
17 | "icons": {
18 | "16": "assets/icon-16.png",
19 | "48": "assets/icon-48.png",
20 | "128": "assets/icon-128.png"
21 | },
22 | "content_scripts": [
23 | {
24 | "matches": [
25 | "*://*.myvisit.com/*"
26 | ],
27 | "js": [
28 | "content-script.js"
29 | ]
30 | }
31 | ],
32 | "host_permissions": [
33 | "*://*.myvisit.com/*"
34 | ],
35 | "permissions": [
36 | "storage",
37 | "cookies",
38 | "webNavigation",
39 | "storage"
40 | ]
41 | }
42 |
43 |
--------------------------------------------------------------------------------
/test/fixtures/location-services-response.fixture.ts:
--------------------------------------------------------------------------------
1 | import { overridable } from '../overridable';
2 | import { LocationServicesResponse } from '@src/lib/api';
3 |
4 | const defaultResponse = (): LocationServicesResponse => {
5 | return {
6 | Success: true,
7 | Results: [
8 | {
9 | serviceId: 2095,
10 | serviceName: 'תיאום פגישה לתיעוד ביומטרי',
11 | serviceDescription: '',
12 | ServiceTypeId: 156,
13 | serviceTypeDescription: '',
14 | description: 'בכל פנייה יינתן שירות לפונה/אדם אחד בלבד',
15 | showStats: false,
16 | waitingTime: 0,
17 | HasCalendarService: true,
18 | DynamicFormsEnabled: true,
19 | HasFIFOService: false,
20 | ExtRef: '',
21 | LocationId: 799,
22 | },
23 | ],
24 | Page: 0,
25 | ResultsPerPage: 0,
26 | TotalResults: 18,
27 | ErrorMessage: null,
28 | ErrorNumber: 0,
29 | Messages: [],
30 | };
31 | };
32 |
33 | export const LocationServicesResponseFixtures = {
34 | valid: overridable(() => defaultResponse()),
35 | };
36 |
--------------------------------------------------------------------------------
/.github/workflows/tests.yml:
--------------------------------------------------------------------------------
1 | # This workflow will do a clean installation of node dependencies, cache/restore them, build the source code and run tests across different versions of node
2 | # For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-nodejs
3 |
4 | name: Testing
5 |
6 | on:
7 | push:
8 | branches: ["main"]
9 |
10 | jobs:
11 | run_tests_and_coverage:
12 | runs-on: ubuntu-latest
13 |
14 | strategy:
15 | matrix:
16 | node-version: [16.x]
17 |
18 | steps:
19 | - uses: actions/checkout@v3
20 | - name: Use Node.js ${{ matrix.node-version }}
21 | uses: actions/setup-node@v3
22 | with:
23 | node-version: ${{ matrix.node-version }}
24 | cache: "npm"
25 | - run: npm ci
26 | - run: npm test -- --coverage
27 | - name: Upload coverage to Codecov
28 | uses: codecov/codecov-action@v3
29 | with:
30 | token: ${{ secrets.CODECOV_TOKEN }}
31 | fail_ci_if_error: true # optional (default = false)
32 | verbose: true # optional (default = false)
--------------------------------------------------------------------------------
/src/lib/utils/date.ts:
--------------------------------------------------------------------------------
1 | import format from 'date-fns/format';
2 |
3 | const ApiDateFormat = 'yyyy-MM-dd';
4 | export const IsraelDateFormat = 'dd-MM-yyyy';
5 |
6 | const padIfNeeded = (digit: number) => (`${digit}`.length === 1 ? `0${digit}` : `${digit}`);
7 |
8 | export const DateUtils = {
9 | isDateInRange: (date: string, startDate: Date, endDate: Date): boolean => {
10 | const dateFormat = new Date(date);
11 | return dateFormat >= startDate && dateFormat <= endDate;
12 | },
13 | isEqual: (currentDate: Date, compareDate: Date): boolean => currentDate.getTime() === compareDate.getTime(),
14 | isBefore: (currentDate: Date, compareDate: Date): boolean => currentDate < compareDate,
15 | isAfter: (currentDate: Date, compareDate: Date): boolean => currentDate > compareDate,
16 | timeSinceMidnightToHour: (timeSinceMidnight: number): string =>
17 | `${padIfNeeded(Math.floor(timeSinceMidnight / 60))}:${padIfNeeded(timeSinceMidnight % 60)}`,
18 | toApiFormattedDate: (date: string | number) => format(new Date(date), ApiDateFormat),
19 | toIsraelFormattedDate: (date: string | number) => format(new Date(date), IsraelDateFormat),
20 | };
21 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2019 Alexander Schwartzberg
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/.github/workflows/pr-checks.yaml:
--------------------------------------------------------------------------------
1 | name: PR Checks
2 |
3 | on:
4 | pull_request:
5 | branches: ["main"]
6 |
7 | jobs:
8 | tests:
9 | needs: lint
10 | runs-on: ubuntu-latest
11 | strategy:
12 | matrix:
13 | node-version: [16.x]
14 |
15 | steps:
16 | - uses: actions/checkout@v3
17 | - name: Use Node.js ${{ matrix.node-version }}
18 | uses: actions/setup-node@v3
19 | with:
20 | node-version: ${{ matrix.node-version }}
21 | cache: "npm"
22 | - run: npm ci
23 | - run: npm test -- --coverage
24 | - name: Upload coverage to Codecov
25 | uses: codecov/codecov-action@v3
26 | with:
27 | token: ${{ secrets.CODECOV_TOKEN }}
28 | fail_ci_if_error: true # optional (default = false)
29 | verbose: true # optional (default = false)
30 |
31 | lint:
32 | runs-on: ubuntu-latest
33 | steps:
34 | - uses: actions/checkout@v3
35 | - uses: actions/setup-node@v3
36 | with:
37 | node-version: 16
38 | cache: "npm"
39 | - run: npm ci
40 | - run: npm run lint:check
41 | - run: npm run prettify:check
42 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE.md:
--------------------------------------------------------------------------------
1 | ## Prerequisites
2 |
3 | Please answer the following questions for yourself before submitting an issue:
4 |
5 | - [ ] I am running the latest version
6 | - [ ] I checked the documentation and found no answer
7 | - [ ] I checked to make sure that this issue has not already been filed
8 | - [ ] I'm reporting the issue to the correct repository (for multi-repository projects)
9 |
10 | ## Context
11 |
12 | Please provide any relevant information about your setup. This is important in case the issue is not reproducible except for under certain conditions.
13 |
14 | ## Expected Behavior
15 |
16 | If relevant, please describe the behavior you are expecting
17 |
18 | ## Current Behavior
19 |
20 | If relevant, describe the current behavior
21 |
22 | ## Failure Information (for bugs)
23 |
24 | Please help provide information about the failure if this is a bug. If it is not a bug, please remove the rest of this template.
25 |
26 | ### Steps to Reproduce
27 |
28 | Please provide detailed steps for reproducing the issue.
29 |
30 | 1. step 1
31 | 2. step 2
32 | 3. you get it...
33 |
34 | ### Failure Logs
35 |
36 | Please include any relevant log snippets or files here.
37 |
--------------------------------------------------------------------------------
/.devcontainer/devcontainer.json:
--------------------------------------------------------------------------------
1 | // For format details, see https://aka.ms/devcontainer.json. For config options, see the
2 | // README at: https://github.com/devcontainers/templates/tree/main/src/javascript-node
3 | {
4 | "name": "Node.js",
5 | // Or use a Dockerfile or Docker Compose file. More info: https://containers.dev/guide/dockerfile
6 | "image": "mcr.microsoft.com/devcontainers/javascript-node:0-16",
7 |
8 | // Features to add to the dev container. More info: https://containers.dev/features.
9 | // "features": {},
10 |
11 | // Use 'forwardPorts' to make a list of ports inside the container available locally.
12 | // "forwardPorts": [],
13 |
14 | // Use 'postCreateCommand' to run commands after the container is created.
15 | "postCreateCommand": "./.devcontainer/post-install.sh",
16 | "customizations": {
17 | "vscode": {
18 | "extensions": [
19 | "GitHub.copilot",
20 | "streetsidesoftware.code-spell-checker",
21 | "eamodio.gitlens",
22 | "redhat.vscode-yaml"
23 | ]
24 | }
25 | }
26 |
27 | // Configure tool-specific properties.
28 | // "customizations": {},
29 |
30 | // Uncomment to connect as root instead. More info: https://aka.ms/dev-containers-non-root.
31 | // "remoteUser": "root"
32 | }
33 |
--------------------------------------------------------------------------------
/src/components/Consent/Consent.tsx:
--------------------------------------------------------------------------------
1 | import React, { FunctionComponent } from 'react';
2 | import { Checkbox } from 'antd';
3 | import { consent as Content } from '@src/content.json';
4 | import { CheckboxChangeEvent } from 'antd/lib/checkbox';
5 | import { AppTestIds } from '@src/components/dataTestIds';
6 |
7 | const TERMS_AND_CONDITIONS_URL = 'https://myvisit.com/templates/directives/termsDialog.html';
8 |
9 | export interface IConsentProps {
10 | consent: boolean;
11 | onConsentChanged: (value: boolean) => void;
12 | }
13 |
14 | export const Consent: FunctionComponent = ({ onConsentChanged, consent }) => {
15 | const onTermsAndConditionsClick = () => {
16 | chrome.tabs.create({
17 | active: true,
18 | url: TERMS_AND_CONDITIONS_URL,
19 | });
20 | };
21 |
22 | const onCheckboxChanged = (e: CheckboxChangeEvent) => onConsentChanged(e.target.checked);
23 |
24 | return (
25 |
26 | {Content.prefix}{' '}
27 |
28 | {Content.termsAndConditions}
29 | {' '}
30 | {Content.suffix}
31 |
32 | );
33 | };
34 |
--------------------------------------------------------------------------------
/.eslintrc.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | parser: '@typescript-eslint/parser', // Specifies the ESLint parser
3 | extends: [
4 | 'plugin:@typescript-eslint/recommended', // Uses the recommended rules from @typescript-eslint/eslint-plugin
5 | 'plugin:react/recommended', // Uses the recommended rules from @eslint-plugin-react
6 | 'prettier/@typescript-eslint', // Uses eslint-config-prettier to disable ESLint rules from @typescript-eslint/eslint-plugin that would conflict with prettier
7 | 'plugin:prettier/recommended', // Enables eslint-plugin-prettier and displays prettier errors as ESLint errors. Make sure this is always the last configuration in the extends array.
8 | ],
9 | parserOptions: {
10 | ecmaVersion: 2018, // Allows for the parsing of modern ECMAScript features
11 | sourceType: 'module', // Allows for the use of imports
12 | ecmaFeatures: {
13 | jsx: true, // Allows for the parsing of JSX
14 | },
15 | },
16 | rules: {
17 | quotes: [2, 'single', { avoidEscape: true }],
18 | 'max-len': [1, { code: 120 }],
19 | },
20 | settings: {
21 | react: {
22 | version: 'detect', // Tells eslint-plugin-react to automatically detect the version of React to use
23 | },
24 | },
25 | };
26 |
--------------------------------------------------------------------------------
/src/lib/api/location-search.ts:
--------------------------------------------------------------------------------
1 | import { CommonResultsResponse } from './common';
2 |
3 | export interface LocationSearchRequest {
4 | currentPage?: number;
5 | isFavorite?: boolean;
6 | orderBy?: string;
7 | organizationId: number;
8 | resultsInPage?: number;
9 | serviceTypeId?: number;
10 | src?: string;
11 | }
12 |
13 | export interface LocationSearchResult {
14 | OrganizationId: number;
15 | OrganizationName: string;
16 | LocationId: number;
17 | LocationName: string;
18 | Address1: string;
19 | Address2: string;
20 | City: string;
21 | State: string;
22 | Country: string;
23 | Description: string;
24 | Directions: string;
25 | ZipCode: string;
26 | Latitude: number;
27 | Longitude: number;
28 | Distance: number;
29 | WaitingTime: number;
30 | ShowStats: boolean;
31 | IsFavorite: boolean;
32 | ServiceCount: number;
33 | LastUseDate: string;
34 | HasCalendarService: boolean;
35 | HasFIFOService: boolean;
36 | ServiceId: number;
37 | ServiceHasFIFO: boolean;
38 | DynamicFormsEnabled: boolean;
39 | PhoneNumber: string;
40 | ExtRef: string;
41 | ServiceTypeId: number;
42 | MaxWaitingTime: number;
43 | }
44 |
45 | export type LocationSearchResponse = CommonResultsResponse;
46 |
--------------------------------------------------------------------------------
/src/content-script/handlers/get-slot-for-calendar/get-slot-for-calendar.ts:
--------------------------------------------------------------------------------
1 | import { BaseHandler } from '@src/content-script/handlers';
2 | import { GetCalendarSlotTask, Priority, TaskType } from '@src/content-script/task';
3 | import { toCalendarSlot, toEnrichedSlot } from '@src/lib/mappers';
4 | import { EnrichedSlot } from '@src/lib/internal-types';
5 |
6 | export class Handler extends BaseHandler {
7 | async handle(task: GetCalendarSlotTask): Promise {
8 | const { httpService, slotsQueue } = this.params;
9 | const { enrichedService, location } = task.params;
10 | const { calendarId, serviceId } = enrichedService;
11 | const slots = await httpService.getAvailableSlotByCalendar(calendarId, serviceId);
12 | const enrichedSlots: EnrichedSlot[] = slots
13 | .map((slot) => toCalendarSlot(enrichedService, slot))
14 | .map((s) => toEnrichedSlot(s, location));
15 | if (enrichedSlots.length > 0) {
16 | const promises = enrichedSlots.map(async (slot) => {
17 | slotsQueue.enqueue({
18 | type: TaskType.ScheduleAppointment,
19 | params: { slot },
20 | priority: Priority.High,
21 | });
22 | });
23 | await Promise.all(promises);
24 | }
25 | }
26 | }
27 |
--------------------------------------------------------------------------------
/src/content-script/handlers/get-service-calendar.ts:
--------------------------------------------------------------------------------
1 | import { BaseHandler, BaseParams } from '@src/content-script/handlers/index';
2 | import { DateUtils } from '@src/lib/utils';
3 | import { GetServiceCalendarTask, Priority, TaskType } from '@src/content-script/task';
4 | import { DateRange } from '@src/lib/internal-types';
5 |
6 | export class Handler extends BaseHandler {
7 | constructor(params: BaseParams, private readonly dateRangeForAppointment: DateRange) {
8 | super(params);
9 | }
10 |
11 | async handle(task: GetServiceCalendarTask): Promise {
12 | const { httpService, priorityQueue } = this.params;
13 | const { serviceId, location } = task.params;
14 | const calendars = await httpService.getCalendars(serviceId, this.dateRangeForAppointment.startDate);
15 | const relevantCalendars = calendars.filter(({ calendarDate }) =>
16 | DateUtils.isDateInRange(
17 | calendarDate,
18 | new Date(this.dateRangeForAppointment.startDate),
19 | new Date(this.dateRangeForAppointment.endDate),
20 | ),
21 | );
22 | relevantCalendars.map((calendar) => {
23 | priorityQueue.enqueue({
24 | type: TaskType.GetCalendarSlot,
25 | params: {
26 | location,
27 | enrichedService: calendar,
28 | },
29 | priority: Priority.Medium,
30 | });
31 | });
32 | }
33 | }
34 |
--------------------------------------------------------------------------------
/src/content-script/task.ts:
--------------------------------------------------------------------------------
1 | import { EnrichedService, EnrichedSlot, Location } from '@src/lib/internal-types';
2 |
3 | export enum Priority {
4 | Low = 1,
5 | Medium = 2,
6 | High = 3,
7 | }
8 |
9 | export enum TaskType {
10 | GetServiceIdByLocationId = 'getServiceIdByLocationId',
11 | GetServiceCalendar = 'getServiceCalendar',
12 | GetCalendarSlot = 'getCalendarSlot',
13 | ScheduleAppointment = 'scheduleAppointment',
14 | }
15 |
16 | export interface BaseTask {
17 | priority: Priority;
18 | }
19 |
20 | export interface GetServiceIdByLocationIdTask extends BaseTask {
21 | type: TaskType.GetServiceIdByLocationId;
22 | params: {
23 | locationId: number;
24 | };
25 | }
26 |
27 | export interface GetServiceCalendarTask extends BaseTask {
28 | type: TaskType.GetServiceCalendar;
29 | params: {
30 | location: Location;
31 | serviceId: number;
32 | };
33 | }
34 |
35 | export interface GetCalendarSlotTask extends BaseTask {
36 | type: TaskType.GetCalendarSlot;
37 | params: {
38 | location: Location;
39 | enrichedService: EnrichedService;
40 | };
41 | }
42 |
43 | export interface ScheduleAppointmentTask extends BaseTask {
44 | type: TaskType.ScheduleAppointment;
45 | params: {
46 | slot: EnrichedSlot;
47 | };
48 | }
49 |
50 | export type Task =
51 | | GetServiceIdByLocationIdTask
52 | | GetServiceCalendarTask
53 | | GetCalendarSlotTask
54 | | ScheduleAppointmentTask;
55 |
--------------------------------------------------------------------------------
/src/lib/api/appointment-cancel.ts:
--------------------------------------------------------------------------------
1 | import { CommonResultsResponse } from './common';
2 |
3 | export interface AppointmentCancelRequest {
4 | visitId: number;
5 | position: string;
6 | url: string;
7 | }
8 |
9 | interface AppointmentCancelResult {
10 | ServiceId: number;
11 | OrganizationId: number;
12 | LocationId: number;
13 | OrganizationName: string;
14 | ServiceName: string;
15 | LocationName: string;
16 | CurrentEntityStatus: number;
17 | CurrentEntityStatusName: string;
18 | CurrentServiceId: number;
19 | QueuePosition: string;
20 | EntityStatusElapsedTime: number;
21 | ReferenceDate: Date;
22 | TicketNumber: string;
23 | Subject: string;
24 | EstimatedWT: number;
25 | SmsAvailable: boolean;
26 | VisitId: number;
27 | QflowProcessId: number;
28 | CancellationAllowed: boolean;
29 | PositionValidated: boolean;
30 | WorkingHoursValidated: boolean;
31 | AllowCustomerToAbandon: boolean;
32 | EarlyArrival: boolean;
33 | EarlinessThreshold: number;
34 | ShowServiceStats: boolean;
35 | ServiceWaitingTime: number;
36 | FreezeDuration: string;
37 | LastSync: number;
38 | IsLateToAppointment: boolean;
39 | CanManageQueue: boolean;
40 | ExtensionTimeAvailable: boolean;
41 | ExtensionTime: number;
42 | ExtensionTimeThreshold: number;
43 | RecallVisitGetInterval: number;
44 | }
45 |
46 | export type AppointmentCancelResponse = CommonResultsResponse;
47 |
--------------------------------------------------------------------------------
/.github/PULL_REQUEST_TEMPLATE.md:
--------------------------------------------------------------------------------
1 | ## Description
2 |
3 | Please include a summary of the change and which issue is fixed. Please also include relevant motivation and context. List any dependencies that are required for this change.
4 |
5 | Fixes # (issue)
6 |
7 | ## Type of change
8 |
9 | Please delete options that are not relevant.
10 |
11 | - [ ] Bug fix (non-breaking change which fixes an issue)
12 | - [ ] New feature (non-breaking change which adds functionality)
13 | - [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected)
14 | - [ ] This change requires a documentation update
15 |
16 | ## How Has This Been Tested?
17 |
18 | Please describe the tests that you ran to verify your changes. Provide instructions so we can reproduce. Please also list any relevant details for your test configuration
19 |
20 | ## Checklist:
21 |
22 | - [ ] My code follows the style guidelines of this project
23 | - [ ] I have performed a self-review of my own code
24 | - [ ] I have commented my code, particularly in hard-to-understand areas
25 | - [ ] I have made corresponding changes to the documentation
26 | - [ ] My changes generate no new warnings
27 | - [ ] I have added tests that prove my fix is effective or that my feature works
28 | - [ ] New and existing unit tests pass locally with my changes
29 | - [ ] Any dependent changes have been merged and published in downstream modules
30 | - [ ] I have checked my code and corrected any misspellings
31 |
--------------------------------------------------------------------------------
/src/content-script/handlers/get-slot-for-calendar/get-slot-for-calendar.test.ts:
--------------------------------------------------------------------------------
1 | import { HandlersDriver } from '@test/handlers.driver';
2 | import { SearchAvailableSlotsResponseFixtures } from '@test/fixtures/search-available-slots-response.fixture';
3 | import { Locations } from '@src/lib/locations';
4 | import { EnrichedService } from '@src/lib/internal-types';
5 | import { Priority, ScheduleAppointmentTask, TaskType } from '@src/content-script/task';
6 |
7 | describe('[GetSlotForCalendar Handler]', () => {
8 | const driver = new HandlersDriver();
9 | const calendarId = 7456;
10 | const serviceId = 2654;
11 | const calendarDate = '2023-05-01';
12 | const defaultLocation = Locations[0];
13 |
14 | beforeEach(driver.reset);
15 |
16 | test('should enqueue slots in priority queue with priority high', async () => {
17 | const slot = { Time: 528 };
18 | const response = SearchAvailableSlotsResponseFixtures.valid({ Results: [slot] });
19 | driver.given.slotsByCalendarId(calendarId, serviceId, response);
20 | const enrichedService: EnrichedService = { serviceId, calendarDate, calendarId };
21 | await driver.when.getSlotForCalendar({ location: defaultLocation, enrichedService });
22 | expect(driver.get.slotsQueue()).toContainEqual({
23 | params: {
24 | slot: expect.objectContaining({
25 | timeSinceMidnight: slot.Time,
26 | }),
27 | },
28 | type: TaskType.ScheduleAppointment,
29 | priority: Priority.High,
30 | });
31 | });
32 | });
33 |
--------------------------------------------------------------------------------
/src/lib/api/appointment-set.ts:
--------------------------------------------------------------------------------
1 | import { CommonResultsResponse } from './common';
2 |
3 | export interface AppointmentSetRequest {
4 | ServiceId: number;
5 | appointmentDate: string; //2022-08-18T00:00:00
6 | appointmentTime: number; //minutes since midnight
7 | position: string; // {"lat":36,"lng":15,"accuracy":9999}
8 | preparedVisitId: number;
9 | }
10 |
11 | export interface AppointmentSetResult {
12 | ServiceId: number;
13 | OrganizationId: number;
14 | LocationId: number;
15 | OrganizationName: string;
16 | ServiceName: string;
17 | LocationName: string;
18 | CurrentEntityStatus: number;
19 | CurrentEntityStatusName: string;
20 | CurrentServiceId: number;
21 | QueuePosition: string;
22 | EntityStatusElapsedTime: number;
23 | ReferenceDate: Date;
24 | TicketNumber: string;
25 | Subject: string;
26 | EstimatedWT: number;
27 | SmsAvailable: boolean;
28 | VisitId: number;
29 | QflowProcessId: number;
30 | CancellationAllowed: boolean;
31 | PositionValidated: boolean;
32 | WorkingHoursValidated: boolean;
33 | AllowCustomerToAbandon: boolean;
34 | EarlyArrival: boolean;
35 | EarlinessThreshold: number;
36 | ShowServiceStats: boolean;
37 | ServiceWaitingTime: number;
38 | FreezeDuration: string;
39 | LastSync: number;
40 | IsLateToAppointment: boolean;
41 | CanManageQueue: boolean;
42 | ExtensionTimeAvailable: boolean;
43 | ExtensionTime: number;
44 | ExtensionTimeThreshold: number;
45 | RecallVisitGetInterval: number;
46 | }
47 |
48 | export type AppointmentSetResponse = CommonResultsResponse;
49 |
--------------------------------------------------------------------------------
/webpack.common.js:
--------------------------------------------------------------------------------
1 | const path = require('path');
2 | const CopyWebpackPlugin = require('copy-webpack-plugin');
3 | const HtmlWebpackPlugin = require('html-webpack-plugin');
4 |
5 | module.exports = {
6 | entry: {
7 | 'background-page': path.join(__dirname, 'src/background-page/background-page.ts'),
8 | popup: path.join(__dirname, 'src/popup/popup.tsx'),
9 | 'content-script': './src/content-script/content-script.ts',
10 | },
11 | output: {
12 | path: path.join(__dirname, 'dist'),
13 | filename: '[name].js',
14 | clean: true,
15 | },
16 | module: {
17 | rules: [
18 | {
19 | exclude: /node_modules/,
20 | test: /\.tsx?$/,
21 | use: 'ts-loader',
22 | },
23 | {
24 | test: /\.(png|jpe?g|gif|eot|ttf|woff|woff2)$/i,
25 | // More information here https://webpack.js.org/guides/asset-modules/
26 | type: 'asset',
27 | },
28 | {
29 | test: /\.svg$/,
30 | use: ['@svgr/webpack'],
31 | },
32 | ],
33 | },
34 | plugins: [
35 | new CopyWebpackPlugin({
36 | patterns: [{ from: 'src/assets', to: './assets' }, { from: './src/manifest.json' }],
37 | }),
38 | new HtmlWebpackPlugin({
39 | filename: 'popup.html',
40 | template: 'src/popup/popup.html',
41 | chunks: ['popup'],
42 | }),
43 | ],
44 | // Setup @src path resolution for TypeScript files
45 | resolve: {
46 | extensions: ['.ts', '.tsx', '.js', '.json'],
47 | alias: {
48 | '@src': path.resolve(__dirname, 'src/'),
49 | '@test': path.resolve(__dirname, 'test/'),
50 | },
51 | },
52 | };
53 |
--------------------------------------------------------------------------------
/src/content-script/handlers/scheduler.ts:
--------------------------------------------------------------------------------
1 | import { BaseHandler, BaseParams } from '@src/content-script/handlers/index';
2 | import { ScheduleAppointmentTask } from '@src/content-script/task';
3 | import { AppointmentScheduler } from '@src/lib/appointment-scheduler';
4 | import { ResponseStatus, SearchStatus, SearchStatusType, UserVisitSuccessData } from '@src/lib/internal-types';
5 | import { ErrorCode } from '@src/lib/constants';
6 | import Content from '@src/content.json';
7 |
8 | export interface ScheduleHandleResponse {
9 | isDone: boolean;
10 | status?: SearchStatus;
11 | }
12 |
13 | export class Handler extends BaseHandler {
14 | constructor(protected readonly params: BaseParams, protected readonly userVisit: UserVisitSuccessData) {
15 | super(params);
16 | }
17 |
18 | async handle(task: ScheduleAppointmentTask): Promise {
19 | const { httpService } = this.params;
20 | const appointmentScheduler = new AppointmentScheduler(httpService);
21 | const res = await appointmentScheduler.scheduleAppointment(this.userVisit, task.params.slot);
22 | if (res.status === ResponseStatus.Success) {
23 | alert(Content.results.scheduledSuccessfully);
24 | return { isDone: true };
25 | } else if (res.data.errorCode === ErrorCode.AlreadyHadAnAppointment) {
26 | alert(Content.results.userHasAppointment);
27 | return { isDone: true };
28 | } else {
29 | return {
30 | isDone: false,
31 | status: { type: SearchStatusType.Warning, message: Content.errors.retryingSetAppointment },
32 | };
33 | }
34 | }
35 | }
36 |
--------------------------------------------------------------------------------
/src/background-page/background-page.ts:
--------------------------------------------------------------------------------
1 | import browser from 'webextension-polyfill';
2 | import { StorageService } from '@src/services/storage';
3 | import { ActionTypes, PlatformMessage } from '@src/platform-message';
4 | import { SearchStatusType } from '@src/lib/internal-types';
5 | import { dispatchSearchStatus } from '@src/lib/utils/status';
6 |
7 | const storageService = new StorageService();
8 |
9 | const VALID_COOKIES_NAMES = ['CentralJwtAnonymous', 'CentralJWTCookie'];
10 |
11 | browser.webNavigation.onCompleted.addListener(
12 | async () => {
13 | browser.cookies.onChanged.addListener(async (changeInfo) => {
14 | if (VALID_COOKIES_NAMES.includes(changeInfo.cookie.name) && !changeInfo.removed) {
15 | await storageService.setLoggedIn(true);
16 | }
17 | });
18 |
19 | const [tab] = await browser.tabs.query({ active: true, currentWindow: true });
20 |
21 | await browser.tabs.sendMessage(tab.id!, { action: ActionTypes.IsLoggedIn } as PlatformMessage);
22 | await dispatchSearchStatus({ type: SearchStatusType.Stopped });
23 | },
24 | { url: [{ hostSuffix: '.myvisit.com' }] },
25 | );
26 |
27 | // Migration scripts
28 | browser.runtime.onInstalled.addListener(async (details) => {
29 | const lastVersion = await storageService.getLastExtensionVersion();
30 |
31 | if (details.reason == 'update') {
32 | if (!lastVersion) {
33 | // This means we updated from a version where there wasn't lastVersion feature
34 | await browser.storage.local.remove('userSearching');
35 | }
36 |
37 | // More update migrations. We can use lastVersion
38 | }
39 |
40 | await storageService.updateLastExtensionVersion();
41 | });
42 |
--------------------------------------------------------------------------------
/src/content.json:
--------------------------------------------------------------------------------
1 | {
2 | "id": {
3 | "label": "תעודת זהות",
4 | "placeholder": "אנא הזן תעודת זהות..."
5 | },
6 | "phone": {
7 | "label": "טלפון",
8 | "placeholder": "אנא הזן ספרות בלבד שמתחילות ב05..."
9 | },
10 | "citiesLabel": "ערים",
11 | "citiesPlaceholder": "חפשו ערים...",
12 | "maxCitiesText": "ניתן לבחור עד 4 ערים:",
13 | "noCitiesFound": "לא נמצאו ערים",
14 | "buttons": {
15 | "search": "חפשו לי תור",
16 | "login": "התחברו",
17 | "loggedIn": "אתם מחוברים!",
18 | "stopSearch": "עצור",
19 | "waiting": "מאתחל..."
20 | },
21 | "statuses": {
22 | "connected": "מחובר",
23 | "disconnected": "מנותק"
24 | },
25 | "numOfDays": "מספר הימים",
26 | "startDateForAppointment": {
27 | "title": "מתאריך:",
28 | "placeholder": "בחרו תאריך ראשון לתור..."
29 | },
30 | "endDateForAppointment": {
31 | "title": "עד תאריך:",
32 | "placeholder": "בחרו תאריך אחרון לתור..."
33 | },
34 | "title": "גם כן בוט",
35 | "consent": {
36 | "prefix": "בהרצת הבוט, אתם מאשרים את",
37 | "termsAndConditions": "תנאי השימוש",
38 | "suffix": "של חברת myVisit"
39 | },
40 | "results": {
41 | "userHasAppointment": "לא ניתן לקבוע תור כי כבר קיים תור פעיל",
42 | "scheduledSuccessfully": "התור נקבע בהצלחה"
43 | },
44 | "errors": {
45 | "noUserData": "אין מספיק נתונים. ודאו שהזנתם את כל הפרטים",
46 | "questionsFailed": "לא הצלחתי להתחבר לשירות חידוש הדרכון :(",
47 | "failingRequests": "חלק מהניסיונות נכשלים או נחסמים, אבל אני עדיין מנסה...",
48 | "authError": "נראה שאתם כבר לא מחוברים. נסו להתחבר שוב",
49 | "retryingSetAppointment": "נמצא תאריך מתאים, עדיין מנסה לתפוס אותו..."
50 | }
51 | }
52 |
--------------------------------------------------------------------------------
/CONTRIBUTING.md:
--------------------------------------------------------------------------------
1 | # Contributing Guide
2 |
3 | Welcome and thanks for stopping by! There are many ways to contribute, including submitting bug reports, improving documentation, submitting feature requests, reviewing new submissions, or contributing code that can be incorporated into the project.
4 |
5 | **Table of Contents:**
6 |
7 | 1. [Code of Conduct](#code-of-conduct)
8 | 2. [Questions](#questions)
9 | 3. [Feature Requests](#feature-requests)
10 | 4. [Reporting Bugs](#reporting-bugs)
11 | 5. [Contributing Code](#contributing-code)
12 |
13 | ## Code of Conduct
14 |
15 | By participating in this project, you agree to abide by our [Code of Conduct][0].
16 |
17 | ## Questions
18 |
19 | Please open a GitHub issue if you have any questions about the project.
20 |
21 | ## Feature Requests
22 |
23 | Please request new features by opening a GitHub issue.
24 |
25 | ## Reporting Bugs
26 |
27 | **If you find a security vulnerability, do NOT open an issue. Email aeksco@gmail.COM instead.**
28 |
29 | Please check open issues before opening a new ticket. Also, provide any references to FAQs or debugging guides that you might have.
30 |
31 | ## Contributing Code
32 |
33 | Unsure where to begin contributing to this project? You can start by looking through open `help-wanted` issues!
34 |
35 | Working on your first open source project or pull request? Here are some helpful tutorials:
36 |
37 | - [How to Contribute to an Open Source Project on GitHub][1]
38 | - [Make a Pull Request][2]
39 | - [First Timers Only][3]
40 |
41 | [0]: CODE_OF_CONDUCT.md
42 | [1]: https://egghead.io/series/how-to-contribute-to-an-open-source-project-on-github
43 | [2]: http://makeapullrequest.com/
44 | [3]: http://www.firsttimersonly.com
45 |
--------------------------------------------------------------------------------
/src/__mocks__/webextension-polyfill.ts:
--------------------------------------------------------------------------------
1 | // src/__mocks__/webextension-polyfill
2 | // Update this file to include any mocks for the `webextension-polyfill` package
3 | // This is used to mock these values for Storybook so you can develop your components
4 | // outside the Web Extension environment provided by a compatible browser
5 | // See .storybook/main.js to see how this module is swapped in for `webextension-polyfill`
6 | import { MessagingTestkit } from '../../test/testkits/messaging.testkit';
7 | import { LocalStorageTestkit } from '../../test/testkits/storage.testkit';
8 |
9 | const messagingTestkit = new MessagingTestkit();
10 |
11 | const browser: any = {
12 | tabs: {
13 | executeScript(currentTabId: number, details: any) {
14 | return Promise.resolve({ done: true });
15 | },
16 | query(params: any): Promise {
17 | return Promise.resolve([{ id: 123 }]);
18 | },
19 | sendMessage: (tabId: number, message: any) => messagingTestkit.sendMessage(message),
20 | },
21 | runtime: {
22 | sendMessage: messagingTestkit.sendMessage,
23 | onMessage: {
24 | addListener: messagingTestkit.addListener,
25 | removeListener: messagingTestkit.removeListener,
26 | },
27 | onInstalled: {
28 | addListener: jest.fn(),
29 | removeListener: jest.fn(),
30 | },
31 | },
32 | storage: {
33 | onChanged: {
34 | addListener: jest.fn(),
35 | removeListener: jest.fn(),
36 | },
37 | local: new LocalStorageTestkit(),
38 | },
39 | webNavigation: {
40 | onCompleted: {
41 | addListener: () => null,
42 | },
43 | },
44 | };
45 | export default browser;
46 |
47 | interface Tab {
48 | id: number;
49 | }
50 |
51 | export interface Tabs {
52 | Tab: Tab;
53 | }
54 |
--------------------------------------------------------------------------------
/src/lib/question-resolver/question-resolver.ts:
--------------------------------------------------------------------------------
1 | import { ErrorCode } from '@src/lib/constants';
2 | import { PrepareVisitData } from '@src/lib/api';
3 |
4 | export enum Answers {
5 | Id = 'ID',
6 | PhoneNumber = 'PHONE_NUMBER',
7 | VisitType = 'VISIT_TYPE',
8 | }
9 |
10 | export const ErrorStrings: Record = {
11 | ['מספר הטלפון']: ErrorCode.PhoneNumberNotValid,
12 | ['תעודת הזהות']: ErrorCode.IdNotValid,
13 | };
14 |
15 | const QuestionsToAnswers: Record> = {
16 | 1674: {
17 | 113: Answers.Id,
18 | },
19 | 1675: {
20 | 114: Answers.PhoneNumber,
21 | },
22 | 201: {
23 | 116: Answers.VisitType,
24 | },
25 | };
26 |
27 | export class QuestionResolver {
28 | static isDone(question: PrepareVisitData): boolean {
29 | return !Boolean(question.QuestionnaireItem);
30 | }
31 |
32 | protected static resolveErrorCode(errorMessage: string): ErrorCode | null {
33 | const errStr = Object.keys(ErrorStrings).find((errStr) => errorMessage.includes(errStr));
34 | return errStr ? ErrorStrings[errStr] : ErrorCode.General;
35 | }
36 |
37 | static hasErrors(question: PrepareVisitData): ErrorCode | null {
38 | const error = question.Validation?.Messages?.[0];
39 | return error ? QuestionResolver.resolveErrorCode(error.Message) : null;
40 | }
41 |
42 | static resolveAnswer(question: PrepareVisitData): Answers {
43 | const { QuestionnaireItemId, QuestionId } = question.QuestionnaireItem;
44 | const maybeAnswer = QuestionsToAnswers[QuestionnaireItemId]?.[QuestionId];
45 | if (maybeAnswer) {
46 | return maybeAnswer;
47 | } else {
48 | throw new Error(`Could not find an answer in the map ${JSON.stringify(question)}`);
49 | }
50 | }
51 | }
52 |
--------------------------------------------------------------------------------
/src/components/MainButton/MainButton.tsx:
--------------------------------------------------------------------------------
1 | import React, { FC } from 'react';
2 | import { Button } from 'antd';
3 | import browser from 'webextension-polyfill';
4 | import { SearchStatusType } from '@src/lib/internal-types';
5 | import { ActionTypes } from '@src/platform-message';
6 | import { buttons as Content } from '@src/content.json';
7 | import { getMyVisitTab } from '@src/lib/utils/tabs';
8 | import styles from './MainButton.scss';
9 | import { AppTestIds } from '../dataTestIds';
10 |
11 | interface MainButtonProps {
12 | searchStatusType: SearchStatusType;
13 | enabled: boolean;
14 | }
15 |
16 | const sendMessageToMyVisitTab = async (action: ActionTypes) => {
17 | const maybeMyVisitTab = await getMyVisitTab();
18 | if (maybeMyVisitTab) {
19 | await browser.tabs.sendMessage(maybeMyVisitTab.id!, { action });
20 | }
21 | };
22 |
23 | export const MainButton: FC = ({ searchStatusType, enabled }) => {
24 | const BUTTON_CONFIGURATIONS = {
25 | STOP: {
26 | onClick: () => sendMessageToMyVisitTab(ActionTypes.StopSearch),
27 | 'data-testid': AppTestIds.STOP_SEARCH_BUTTON,
28 | content: Content.stopSearch,
29 | },
30 | WAIT: {
31 | disabled: true,
32 | content: Content.waiting,
33 | },
34 | START: {
35 | onClick: () => sendMessageToMyVisitTab(ActionTypes.StartSearch),
36 | 'data-testid': AppTestIds.START_SEARCH_BUTTON,
37 | content: Content.search,
38 | disabled: !enabled,
39 | },
40 | };
41 |
42 | const { content, ...config } = [SearchStatusType.Started, SearchStatusType.Warning].includes(searchStatusType)
43 | ? BUTTON_CONFIGURATIONS.STOP
44 | : searchStatusType === SearchStatusType.Waiting
45 | ? BUTTON_CONFIGURATIONS.WAIT
46 | : BUTTON_CONFIGURATIONS.START;
47 |
48 | return (
49 |
50 |
51 |
52 | );
53 | };
54 |
--------------------------------------------------------------------------------
/src/lib/constants.ts:
--------------------------------------------------------------------------------
1 | export const OrganizationID = 56;
2 | export const MockPosition = JSON.stringify({ lat: 36, lng: 15, accuracy: 9999 });
3 |
4 | export enum ServiceIds {
5 | BiometricPassportAppointment = 156,
6 | }
7 |
8 | export enum City {
9 | Rahat = 'רהט',
10 | TelAviv = 'תל אביב',
11 | Givataim = 'גבעתיים',
12 | BeerSheva = 'באר שבע',
13 | Netivot = 'נתיבות',
14 | RoshHaayin = 'ראש העין',
15 | BneiBrak = 'בני ברק',
16 | Jerusalem = 'ירושלים',
17 | Holon = 'חולון',
18 | Rehovot = 'רחובות',
19 | Ramla = 'רמלה',
20 | Netanya = 'נתניה',
21 | Hadera = 'חדרה',
22 | PetaTivka = 'פתח תקוה',
23 | KfarSaba = 'כפר סבא',
24 | Zefad = 'צפת',
25 | Karmiel = 'כרמיאל',
26 | RishonLezion = 'ראשון לציון',
27 | Herzlia = 'הרצליה',
28 | Eilat = 'אילת',
29 | Sahnin = 'סחנין',
30 | MaaleAdomim = 'מעלה אדומים',
31 | ModiinElite = 'מודיעין עילית',
32 | MaalotTarshiha = 'מעלות תרשיחא',
33 | KiryatAta = 'קרית אתא',
34 | Taybee = 'טייבה',
35 | Yokneaam = 'יקנעם',
36 | KiryatGat = 'קרית גת',
37 | Katzrin = 'קצרין',
38 | DirElAssad = 'דיר אל אסד',
39 | Dimona = 'דימונה',
40 | Sderot = 'שדרות',
41 | Ariel = 'אריאל',
42 | KiryatShomna = 'קרית שמונה',
43 | OmElFahem = 'אום אל פחם',
44 | BeitShemesh = 'בית שמש',
45 | Modiin = 'מודיעין',
46 | MevaseretZion = 'מבשרת ציון',
47 | NofHagalil = 'נוף הגליל',
48 | Tveria = 'טבריה',
49 | Afula = 'עפולה',
50 | Haifa = 'חיפה',
51 | Ashkelon = 'אשקלון',
52 | Ashdod = 'אשדוד',
53 | Nahariya = 'נהריה',
54 | Akko = 'עכו',
55 | }
56 |
57 | export enum ErrorCode {
58 | General = 100,
59 | IdNotValid = 101,
60 | PhoneNumberNotValid = 102,
61 | AlreadyHadAnAppointment = 103,
62 | SetAppointmentGeneralError = 104,
63 | NoCityFoundForUser = 105,
64 | }
65 |
--------------------------------------------------------------------------------
/src/lib/appointment-scheduler.ts:
--------------------------------------------------------------------------------
1 | import { AppointmentSetRequest, AppointmentSetResponse } from '@src/lib/api';
2 | import {
3 | aFailedResponse,
4 | aSuccessResponse,
5 | EnrichedSlot,
6 | SetAppointmentResponse,
7 | UserVisitSuccessData,
8 | } from '@src/lib/internal-types';
9 | import { ErrorCode, MockPosition } from '@src/lib/constants';
10 | import { HttpService } from '@src/lib/http';
11 |
12 | enum ErrorStrings {
13 | DoubleBook = 'לא ניתן לתאם תור חדש לפני ביטול התור הקיים',
14 | }
15 |
16 | export class AppointmentScheduler {
17 | constructor(private readonly httpService: HttpService) {}
18 |
19 | private static resolveError(response: AppointmentSetResponse): ErrorCode {
20 | if (response.ErrorMessage === 'General server error') {
21 | return ErrorCode.SetAppointmentGeneralError;
22 | } else if (Array.isArray(response.Messages)) {
23 | const errorStr = response.Messages.join('');
24 | if (errorStr.includes(ErrorStrings.DoubleBook)) {
25 | return ErrorCode.AlreadyHadAnAppointment;
26 | } else {
27 | return ErrorCode.General;
28 | }
29 | }
30 | return ErrorCode.General;
31 | }
32 |
33 | async scheduleAppointment(userVisit: UserVisitSuccessData, slot: EnrichedSlot): Promise {
34 | const { serviceId, date, timeSinceMidnight } = slot;
35 | const { visitId, visitToken } = userVisit;
36 | const setAppointmentRequest: AppointmentSetRequest = {
37 | ServiceId: serviceId,
38 | appointmentDate: date,
39 | appointmentTime: timeSinceMidnight,
40 | position: MockPosition,
41 | preparedVisitId: visitId,
42 | };
43 |
44 | try {
45 | const response = await this.httpService.setAppointment(visitToken, setAppointmentRequest);
46 | return response?.Success
47 | ? aSuccessResponse(response.Results!)
48 | : aFailedResponse(AppointmentScheduler.resolveError(response!));
49 | } catch (err) {
50 | return aFailedResponse(ErrorCode.General);
51 | }
52 | }
53 | }
54 |
--------------------------------------------------------------------------------
/test/RTL/RTLPageBaseDriver.tsx:
--------------------------------------------------------------------------------
1 | import { render, waitFor, RenderResult, fireEvent } from '@testing-library/react';
2 | import { App } from '@src/components/App';
3 | import React from 'react';
4 | import Content from '@src/content.json';
5 | import { RTLUserMetadataDriver } from '@test/RTL/RTLUserMetadataDriver';
6 | import { AppTestIds } from '@src/components/dataTestIds';
7 | import browser from 'webextension-polyfill';
8 |
9 | export class PageBaseDriver {
10 | private renderResult!: RenderResult;
11 | public userMetadataDriver!: RTLUserMetadataDriver;
12 |
13 | async mount() {
14 | await this.clearStorage();
15 | this.renderResult = render();
16 | await waitFor(() => this.get.title());
17 | this.userMetadataDriver = new RTLUserMetadataDriver(this.renderResult);
18 | }
19 |
20 | private async clearStorage() {
21 | return browser.storage.local.clear();
22 | }
23 |
24 | get = {
25 | title: () => this.renderResult.getByText(Content.title),
26 |
27 | disconnectedStatus: () => this.renderResult.getByText(Content.statuses.disconnected),
28 |
29 | startButton: () => this.renderResult.getByTestId(AppTestIds.START_SEARCH_BUTTON),
30 |
31 | clickConsent: () => {
32 | return this.renderResult.getByTestId(AppTestIds.CONSENT_CHECKBOX);
33 | },
34 | };
35 |
36 | given = {
37 | userMetadata: (props: {
38 | userId: string;
39 | phoneNumber: string;
40 | cities: string[];
41 | startDate: string;
42 | endDate: string;
43 | }) => {
44 | this.userMetadataDriver.when.fillUserId(props.userId);
45 | this.userMetadataDriver.when.fillPhoneNumber(props.phoneNumber);
46 | this.userMetadataDriver.when.selectCities(props.cities);
47 | this.userMetadataDriver.when.chooseStartDate(props.startDate);
48 | this.userMetadataDriver.when.chooseEndDate(props.endDate);
49 | },
50 | };
51 |
52 | when = {
53 | clickConsent: () => {
54 | const checkbox = this.renderResult.getByTestId(AppTestIds.CONSENT_CHECKBOX);
55 | fireEvent.click(checkbox);
56 | },
57 | };
58 | }
59 |
--------------------------------------------------------------------------------
/src/content-script/handlers/get-service-by-location/get-service-by-location.ts:
--------------------------------------------------------------------------------
1 | import { BaseHandler, BaseParams } from '@src/content-script/handlers';
2 | import { GetServiceIdByLocationIdTask, Priority, Task, TaskType } from '@src/content-script/task';
3 | import { Location, Service } from '@src/lib/internal-types';
4 |
5 | export class Handler extends BaseHandler {
6 | constructor(protected readonly params: BaseParams, private readonly locations: Location[]) {
7 | super(params);
8 | }
9 |
10 | private createServiceToLocationMap(services: Service[]): Record {
11 | return services.reduce>((acc, service) => {
12 | return {
13 | ...acc,
14 | [service.id]: this.locations.find((location) => location.id === service.locationId)!,
15 | };
16 | }, {});
17 | }
18 |
19 | private publishTask(serviceId: number, location: Location) {
20 | const { priorityQueue } = this.params;
21 | const task: Task = {
22 | type: TaskType.GetServiceCalendar,
23 | params: { location, serviceId },
24 | priority: Priority.Medium,
25 | };
26 | priorityQueue.enqueue(task);
27 | }
28 |
29 | private async getServices(locationId: number): Promise {
30 | const { httpService } = this.params;
31 | const maybeServices = await this.params.storage.getServiceIdByLocationId(locationId);
32 | if (maybeServices) {
33 | return maybeServices;
34 | } else {
35 | const services = await httpService.getServiceIdByLocationId(locationId);
36 | await this.params.storage.setServiceIdByLocationId(locationId, services);
37 | return services;
38 | }
39 | }
40 |
41 | async handle(task: GetServiceIdByLocationIdTask): Promise {
42 | const { locationId } = task.params;
43 | const services = await this.getServices(locationId);
44 | const serviceIdLocationsMap = this.createServiceToLocationMap(services);
45 | services.map((service) => {
46 | this.publishTask(service.id, serviceIdLocationsMap[service.id]);
47 | });
48 | }
49 | }
50 |
--------------------------------------------------------------------------------
/src/hooks/userMetadata.ts:
--------------------------------------------------------------------------------
1 | import { useEffect, useState } from 'react';
2 | import { StorageService, UserMetadata } from '@src/services/storage';
3 | import {
4 | validateEndDate,
5 | validateIsraeliIdNumber,
6 | validateNumberOfAllowedCities,
7 | validatePhoneNumber,
8 | validateStartDate,
9 | } from '@src/validators/validators';
10 | import debounce from 'lodash.debounce';
11 | import addDays from 'date-fns/addDays';
12 |
13 | type SetMetadataFunction = {
14 | (value: T): void;
15 | };
16 |
17 | export type SetMetadata = (property: keyof UserMetadata) => SetMetadataFunction;
18 |
19 | export const useUserMetadata = (storageService: StorageService) => {
20 | const [userMetadata, setUserMetadata] = useState({
21 | phone: '',
22 | cities: [],
23 | id: '',
24 | startDate: new Date().getTime(),
25 | endDate: addDays(new Date(), 14).getTime(),
26 | });
27 | const { id, phone, cities, startDate, endDate } = userMetadata;
28 |
29 | useEffect(() => {
30 | storageService.getUserMetadata().then((metadata) => {
31 | if (metadata) {
32 | initializeMetadata(metadata);
33 | }
34 | });
35 | }, []);
36 |
37 | const setDataInCache = debounce((userMetadata) => storageService.setUserMetadata(userMetadata), 500);
38 |
39 | const initializeMetadata = (metadata: UserMetadata) => {
40 | const { cities, phone, id, startDate, endDate } = metadata;
41 | setUserMetadata({ cities, phone, id, startDate, endDate });
42 | };
43 |
44 | const setMetadata: SetMetadata =
45 | (property: keyof UserMetadata) =>
46 | (value: T) => {
47 | const newMetadata = { ...userMetadata, ...{ [property]: value } };
48 | setUserMetadata(newMetadata);
49 | setDataInCache(newMetadata);
50 | };
51 |
52 | const isValidMetadata =
53 | validateIsraeliIdNumber(id) &&
54 | validatePhoneNumber(phone) &&
55 | validateNumberOfAllowedCities(cities) &&
56 | validateStartDate(startDate, endDate) &&
57 | validateEndDate(endDate, startDate);
58 |
59 | return { userMetadata, isValidMetadata, setMetadata };
60 | };
61 |
--------------------------------------------------------------------------------
/src/lib/api/prepare-visit.ts:
--------------------------------------------------------------------------------
1 | import { CommonDataResponse } from './common';
2 |
3 | export interface AnswerQuestionRequest {
4 | PreparedVisitToken: string;
5 | QuestionnaireItemId: number;
6 | QuestionId: number;
7 | AnswerIds: number[] | null;
8 | AnswerText: string | null;
9 | }
10 |
11 | interface ValidationMessage {
12 | Message: string;
13 | Type: string;
14 | }
15 | export interface Validation {
16 | Messages: ValidationMessage[];
17 | }
18 |
19 | export interface NavigationState {
20 | State?: any;
21 | OrganizationId: number;
22 | ServiceTypeId: number;
23 | LocationId: number;
24 | ServiceId: number;
25 | QflowServiceId: number;
26 | }
27 |
28 | export interface Question {
29 | AskOncePerCustomer: boolean;
30 | QuestionId: number;
31 | OrganizationId: number;
32 | IsActive: boolean;
33 | Title: string;
34 | Text: string;
35 | Description: string;
36 | CustomErrorText: string;
37 | Type: number;
38 | MappedTo: number;
39 | MappedToCustomerCustomPropertyId: number;
40 | MappedToProcessCustomPropertyId: number;
41 | MappedToPropertyName: string;
42 | Required: boolean;
43 | ValidateAnswerOnQFlow: boolean;
44 | ValidateAnswerOnClient: boolean;
45 | ValidationExpression: string;
46 | QuestionKey: string;
47 | Answers: any[];
48 | ExtRef: string;
49 | SaveAnswerWithAppointment: boolean;
50 | }
51 |
52 | export interface QuestionnaireItem {
53 | QuestionnaireItemId: number;
54 | ServiceTypeId: number;
55 | OrganizationId?: any;
56 | QuestionId: number;
57 | Position: number;
58 | IsActive: boolean;
59 | Question: Question;
60 | }
61 |
62 | export interface NavigationState {
63 | State?: any;
64 | OrganizationId: number;
65 | ServiceTypeId: number;
66 | LocationId: number;
67 | ServiceId: number;
68 | QflowServiceId: number;
69 | }
70 |
71 | export interface PrepareVisitData {
72 | PreparedVisitId: number;
73 | UserId: number;
74 | ServiceId: number;
75 | ServiceTypeId: number;
76 | OrganizationId?: any;
77 | DateCreated: Date;
78 | PreparedVisitToken: string;
79 | QuestionnaireItem: QuestionnaireItem;
80 | Validation?: Validation;
81 | NavigationState?: NavigationState;
82 | IsUserIdentify?: any;
83 | }
84 |
85 | export type PrepareVisitResponse = CommonDataResponse;
86 |
--------------------------------------------------------------------------------
/CODE_OF_CONDUCT.md:
--------------------------------------------------------------------------------
1 | # Code of Conduct
2 |
3 | All participants of `react-typescript-web-extension-starter` are expected to abide by our Code of Conduct, both online and during in-person events that are hosted and/or associated with `react-typescript-web-extension-starter`.
4 |
5 | ## The Pledge
6 |
7 | In the interest of fostering an open and welcoming environment, we pledge to make participation in our project and our community a harassment-free experience for everyone, regardless of age, body size, disability, ethnicity, gender identity and expression, level of experience, nationality, personal appearance, race, religion, or sexual identity and orientation.
8 |
9 | ## The Standards
10 |
11 | Examples of behaviour that contributes to creating a positive environment include:
12 |
13 | - Using welcoming and inclusive language
14 | - Being respectful of differing viewpoints and experiences
15 | - Gracefully accepting constructive criticism
16 |
17 | Examples of unacceptable behaviour by participants include:
18 |
19 | - Trolling, insulting/derogatory comments, public or private harassment
20 | - Publishing others' private information, such as a physical or electronic address, without explicit permission
21 | - Not being respectful to reasonable communication boundaries, such as 'leave me alone,' 'go away,' or 'I’m not discussing this with you.'
22 | - The usage of sexualised language or imagery and unwelcome sexual attention or advances
23 | - Demonstrating the graphics or any other content you know may be considered disturbing
24 | - Starting and/or participating in arguments related to politics
25 | - Other conduct which you know could reasonably be considered inappropriate in a professional setting.
26 |
27 | ## Enforcement
28 |
29 | Violations of the Code of Conduct may be reported by sending an email to `aeksco at gmail.com`. All reports will be reviewed and investigated and will result in a response that is deemed necessary and appropriate to the circumstances. Further details of specific enforcement policies may be posted separately.
30 |
31 | We hold the right and responsibility to remove comments or other contributions that are not aligned to this Code of Conduct, or to ban temporarily or permanently any members for other behaviours that they deem inappropriate, threatening, offensive, or harmful.
32 |
33 | ## Attribution
34 |
35 | This Code of Conduct is adapted from dev.to.
36 |
--------------------------------------------------------------------------------
/src/validators/validators.ts:
--------------------------------------------------------------------------------
1 | import { DateUtils } from '@src/lib/utils';
2 | import startOfDay from 'date-fns/startOfDay';
3 |
4 | export const validateIsraeliIdNumber = (id: any): boolean => {
5 | if (!id) {
6 | return false;
7 | }
8 | id = String(id).trim();
9 | if (id.length !== 9 || isNaN(Number(id))) return false;
10 | return (
11 | Array.from(id, Number).reduce((counter: number, digit: number, i: number) => {
12 | const step = digit * ((i % 2) + 1);
13 | return counter + (step > 9 ? step - 9 : step);
14 | }, 0) %
15 | 10 ===
16 | 0
17 | );
18 | };
19 |
20 | export const validatePhoneNumber = (inputString: string): boolean => {
21 | const regex = /^05\d{8}$/;
22 | return regex.test(inputString);
23 | };
24 |
25 | export const validateNumberOfAllowedCities = (cities: string[]): boolean => {
26 | return cities.length > 0 && cities.length <= 4;
27 | };
28 |
29 | export const validateEndDate = (endDate: number, startDate?: number): boolean => {
30 | if (endDate === 0) {
31 | return false;
32 | }
33 |
34 | const startOfEndDate = startOfDay(new Date(endDate));
35 | const startOfTodayDate = startOfDay(new Date());
36 | const startOfStartDate = startDate ? startOfDay(new Date(startDate)) : undefined;
37 |
38 | const isAfterOrEqualToday =
39 | DateUtils.isAfter(startOfEndDate, startOfTodayDate) || DateUtils.isEqual(startOfEndDate, startOfTodayDate);
40 |
41 | if (startOfStartDate) {
42 | const isAfterOrEqualStartDate =
43 | DateUtils.isAfter(startOfEndDate, startOfStartDate) || DateUtils.isEqual(startOfEndDate, startOfStartDate);
44 |
45 | return isAfterOrEqualToday && isAfterOrEqualStartDate;
46 | }
47 |
48 | return isAfterOrEqualToday;
49 | };
50 |
51 | export const validateStartDate = (startDate: number, endDate?: number): boolean => {
52 | if (startDate === 0) {
53 | return false;
54 | }
55 |
56 | const startOfStartDate = startOfDay(new Date(startDate));
57 | const startOfTodayDate = startOfDay(new Date());
58 | const startOfEndDate = endDate ? startOfDay(new Date(endDate)) : undefined;
59 |
60 | const isAfterOrEqualToday =
61 | DateUtils.isAfter(startOfStartDate, startOfTodayDate) || DateUtils.isEqual(startOfStartDate, startOfTodayDate);
62 |
63 | if (startOfEndDate) {
64 | const isBeforeOrEqualEndDate =
65 | DateUtils.isBefore(startOfStartDate, startOfEndDate) || DateUtils.isEqual(startOfStartDate, startOfEndDate);
66 | return isAfterOrEqualToday && isBeforeOrEqualEndDate;
67 | }
68 |
69 | return isAfterOrEqualToday;
70 | };
71 |
--------------------------------------------------------------------------------
/src/lib/internal-types.ts:
--------------------------------------------------------------------------------
1 | import { ErrorCode } from './constants';
2 | import { AppointmentSetResult, PrepareVisitData } from './api';
3 |
4 | export interface CalendarSlot {
5 | date: string;
6 | timeSinceMidnight: number;
7 | serviceId: number;
8 | }
9 |
10 | export interface EnrichedService {
11 | serviceId: number;
12 | calendarDate: string;
13 | calendarId: number;
14 | }
15 |
16 | export interface Location {
17 | id: number;
18 | city: string;
19 | name: string;
20 | description: string;
21 | address: string;
22 | }
23 |
24 | export interface Service {
25 | id: number;
26 | name: string;
27 | description: string;
28 | locationId: number;
29 | }
30 |
31 | export interface EnrichedSlot {
32 | serviceId: number;
33 | date: string;
34 | timeSinceMidnight: number;
35 | city: string;
36 | address: string;
37 | branchName: string;
38 | }
39 |
40 | export interface User {
41 | id: string;
42 | firstName: string;
43 | lastName: string;
44 | phone: string;
45 | }
46 |
47 | export interface DateRange {
48 | startDate: number;
49 | endDate: number;
50 | }
51 |
52 | export interface UserVisitSuccessData {
53 | visitId: number;
54 | visitToken: string;
55 | }
56 |
57 | export interface Appointment {
58 | hour: string;
59 | date: string;
60 | city: string;
61 | address: string;
62 | branchName: string;
63 | }
64 |
65 | export enum ResponseStatus {
66 | Success = 'Success',
67 | Failed = 'Failed',
68 | }
69 |
70 | export interface ResponseSuccess {
71 | status: ResponseStatus.Success;
72 | data: T;
73 | }
74 |
75 | export interface ResponseFailed {
76 | status: ResponseStatus.Failed;
77 | data: {
78 | errorCode: ErrorCode;
79 | };
80 | }
81 |
82 | export enum SearchStatusType {
83 | Waiting = 'SEARCH_WAITING',
84 | Started = 'SEARCH_STARTED',
85 | Warning = 'SEARCH_WARNING',
86 | Error = 'SEARCH_ERROR',
87 | Stopped = 'SEARCH_STOPPED',
88 | }
89 |
90 | export interface SearchStatus {
91 | type: SearchStatusType;
92 | message?: string;
93 | }
94 |
95 | export type ResponseWrapper = ResponseSuccess | ResponseFailed;
96 |
97 | export type UserVisitResponse = ResponseWrapper;
98 |
99 | export type SetAppointmentResponse = ResponseWrapper;
100 |
101 | export type PrepareVisitResponse = ResponseWrapper;
102 |
103 | export const aFailedResponse = (errorCode: ErrorCode): ResponseFailed => ({
104 | status: ResponseStatus.Failed,
105 | data: {
106 | errorCode,
107 | },
108 | });
109 |
110 | export const aSuccessResponse = (data: T): ResponseSuccess => ({
111 | status: ResponseStatus.Success,
112 | data,
113 | });
114 |
--------------------------------------------------------------------------------
/src/lib/visit.ts:
--------------------------------------------------------------------------------
1 | import { HttpService } from '@src/lib/http';
2 | import { AnswerQuestionRequest, PrepareVisitData } from '@src/lib/api';
3 | import { Answers, QuestionResolver } from '@src/lib/question-resolver/question-resolver';
4 | import {
5 | aFailedResponse,
6 | aSuccessResponse,
7 | PrepareVisitResponse,
8 | ResponseStatus,
9 | User,
10 | UserVisitResponse,
11 | } from '@src/lib/internal-types';
12 | import { ErrorCode } from '@src/lib/constants';
13 | import { UserMetadata } from '@src/services/storage';
14 |
15 | export class VisitService {
16 | constructor(private readonly httpService: HttpService) {}
17 |
18 | private buildAnswerRequestFrame(question: PrepareVisitData): Omit {
19 | return {
20 | PreparedVisitToken: question.PreparedVisitToken,
21 | QuestionId: question.QuestionnaireItem.QuestionId,
22 | QuestionnaireItemId: question.QuestionnaireItem.QuestionnaireItemId,
23 | };
24 | }
25 |
26 | private extractDataByAnswer(answer: Answers, userMetadata: UserMetadata): string {
27 | switch (answer) {
28 | case Answers.Id:
29 | return userMetadata.id;
30 | case Answers.PhoneNumber:
31 | return userMetadata.phone;
32 | }
33 | return '';
34 | }
35 |
36 | private async answer(question: PrepareVisitData, userMetadata: UserMetadata): Promise {
37 | if (QuestionResolver.isDone(question)) {
38 | return aSuccessResponse(question);
39 | }
40 |
41 | if (QuestionResolver.hasErrors(question)) {
42 | return aFailedResponse(QuestionResolver.hasErrors(question) as ErrorCode);
43 | }
44 |
45 | const whatToAnswer = QuestionResolver.resolveAnswer(question);
46 | const request: AnswerQuestionRequest = {
47 | ...this.buildAnswerRequestFrame(question),
48 | ...(whatToAnswer === Answers.VisitType
49 | ? {
50 | AnswerIds: [77],
51 | AnswerText: null,
52 | }
53 | : {
54 | AnswerIds: null,
55 | AnswerText: this.extractDataByAnswer(whatToAnswer, userMetadata),
56 | }),
57 | };
58 | const nextQuestion = await this.httpService.answer(request);
59 | return this.answer(nextQuestion, userMetadata);
60 | }
61 |
62 | async prepare(userMetadata: UserMetadata): Promise {
63 | const initialQuestion = await this.httpService.prepareVisit();
64 | const response = await this.answer(initialQuestion, userMetadata);
65 | return response.status === ResponseStatus.Success
66 | ? aSuccessResponse({
67 | visitId: response.data.PreparedVisitId,
68 | visitToken: response.data.PreparedVisitToken,
69 | })
70 | : aFailedResponse(response.data.errorCode);
71 | }
72 | }
73 |
--------------------------------------------------------------------------------
/src/components/App/App.tsx:
--------------------------------------------------------------------------------
1 | import React, { FunctionComponent, useEffect, useState } from 'react';
2 | import { StorageService } from '@src/services/storage';
3 | import { Typography } from 'antd';
4 | import Content from '@src/content.json';
5 | import styles from './App.scss';
6 | import browser from 'webextension-polyfill';
7 | import GamKenBot from '@src/assets/gamkenbot.svg';
8 | import { Consent } from '@src/components/Consent/Consent';
9 | import { UserMetadata } from '@src/components/UserMetadata/UserMetadata';
10 | import { LoginStatus } from '@src/components/LoginStatus/LoginStatus';
11 | import { useUserMetadata } from '@src/hooks/userMetadata';
12 | import { ActionTypes, PlatformMessage } from '@src/platform-message';
13 | import { SearchStatusType, SearchStatus } from '@src/lib/internal-types';
14 | import { MainButton } from '../MainButton/MainButton';
15 | import { SearchMessage } from '../MainButton/SearchMessage';
16 |
17 | const { Title } = Typography;
18 |
19 | const storageService = new StorageService();
20 |
21 | export const App: FunctionComponent = () => {
22 | const { userMetadata, isValidMetadata, setMetadata } = useUserMetadata(storageService);
23 | const [consent, setConsent] = useState(false);
24 | const [searchStatus, setSearchStatus] = useState({ type: SearchStatusType.Stopped });
25 |
26 | useEffect(() => {
27 | storageService.getConsent().then(setConsent);
28 | storageService.getSearchStatus().then(setSearchStatus);
29 | }, []);
30 |
31 | useEffect(() => {
32 | const listener = (message: PlatformMessage) => {
33 | if (message.action == ActionTypes.SetSearchStatus) {
34 | setSearchStatus(message.status);
35 | }
36 | };
37 |
38 | browser.runtime.onMessage.addListener(listener);
39 |
40 | return () => browser.runtime.onMessage.removeListener(listener);
41 | }, []);
42 |
43 | const setUserConsent = (val: boolean) => {
44 | setConsent(val);
45 | void storageService.setConsent(val);
46 | };
47 |
48 | const submitEnabled = consent && isValidMetadata;
49 |
50 | return (
51 |
52 |
53 |
54 |
55 |
{Content.title}
56 |
57 |
58 |
59 |
60 |
61 |
62 |
63 |
64 |
65 |
66 |
67 | {searchStatus.message &&
}
68 |
69 |
70 |
71 | );
72 | };
73 |
--------------------------------------------------------------------------------
/src/components/UserMetadata/UserMetadata.tsx:
--------------------------------------------------------------------------------
1 | import React, { FunctionComponent } from 'react';
2 | import styles from './UserMetadata.scss';
3 | import { Input, Select, Typography } from 'antd';
4 | import Content from '@src/content.json';
5 | import {
6 | validateIsraeliIdNumber,
7 | validateNumberOfAllowedCities,
8 | validatePhoneNumber,
9 | } from '@src/validators/validators';
10 | import { UserMetadataTestIds } from '@src/components/dataTestIds';
11 | import { DateOptions, DateRangePicker } from '@src/components/DateRangePicker/DateRangePicker';
12 | import { Locations } from '@src/lib/locations';
13 | import { UserMetadata as UserMetadataType } from '@src/services/storage';
14 | import { SetMetadata } from '@src/hooks/userMetadata';
15 |
16 | const { Text } = Typography;
17 |
18 | interface IUserMetadata {
19 | userMetadata: UserMetadataType;
20 | onMetadataChanged: SetMetadata;
21 | }
22 |
23 | const ALL_CITIES = Array.from(new Set(Locations.map((location) => location.city))).map((value) => ({ value }));
24 |
25 | export const UserMetadata: FunctionComponent = ({ userMetadata, onMetadataChanged }) => {
26 | const onDateChanged = (dateSelected: Date, dateOption: DateOptions) => {
27 | onMetadataChanged(dateOption)(new Date(dateSelected).getTime());
28 | };
29 |
30 | return (
31 |
32 | onMetadataChanged('id')(e.target.value)}
40 | data-testid={UserMetadataTestIds.ID}
41 | />
42 | onMetadataChanged('phone')(e.target.value)}
50 | data-testid={UserMetadataTestIds.PHONE_NUMBER}
51 | />
52 | {Content.maxCitiesText}
53 |
64 |
69 |
70 | );
71 | };
72 |
--------------------------------------------------------------------------------
/test/RTL/RTLUserMetadataDriver.tsx:
--------------------------------------------------------------------------------
1 | import { fireEvent, RenderResult, screen } from '@testing-library/react';
2 | import userEvent from '@testing-library/user-event';
3 |
4 | import { DatePickerTestIds, UserMetadataTestIds } from '@src/components/dataTestIds';
5 |
6 | export class RTLUserMetadataDriver {
7 | constructor(private readonly renderResult: RenderResult) {}
8 |
9 | private getStartDateElement() {
10 | return this.renderResult.getByTestId(DatePickerTestIds.START_DATE_PICKER);
11 | }
12 |
13 | private getEndDateElement() {
14 | return this.renderResult.getByTestId(DatePickerTestIds.END_DATE_PICKER);
15 | }
16 |
17 | private getUserId() {
18 | return this.renderResult.getByTestId(UserMetadataTestIds.ID);
19 | }
20 |
21 | private getPhoneNumber() {
22 | return this.renderResult.getByTestId(UserMetadataTestIds.PHONE_NUMBER);
23 | }
24 |
25 | private setDate(element: Element, updatedDate: string) {
26 | fireEvent.mouseDown(element);
27 | fireEvent.change(element, { target: { value: updatedDate } });
28 | const calenderDate = document.querySelector('.ant-picker-cell-selected') as Element;
29 | fireEvent.click(calenderDate);
30 | }
31 |
32 | get = {
33 | userId: () => this.getUserId().getAttribute('value'),
34 |
35 | phoneNumber: () => this.getPhoneNumber().getAttribute('value'),
36 |
37 | startDate: () => this.getStartDateElement().getAttribute('value'),
38 |
39 | endDate: () => this.getEndDateElement().getAttribute('value'),
40 | };
41 |
42 | when = {
43 | fillUserId: (userId: string) => {
44 | const element = this.getUserId();
45 | fireEvent.change(element, { target: { value: userId } });
46 | },
47 |
48 | fillPhoneNumber: (phoneNumber: string) => {
49 | const element = this.getPhoneNumber();
50 | fireEvent.change(element, { target: { value: phoneNumber } });
51 | },
52 |
53 | selectCities: (cities: string[]) => {
54 | const select = screen.getByRole('combobox');
55 | userEvent.click(select);
56 |
57 | cities.forEach((city) => {
58 | const cityOptions = screen.getAllByText(city);
59 | const selectedOption = cityOptions[cityOptions.length - 1];
60 | userEvent.click(selectedOption);
61 | });
62 | },
63 |
64 | chooseStartDate: (startDate: string) => {
65 | const element = this.getStartDateElement();
66 | fireEvent.mouseDown(element);
67 | fireEvent.change(element, { target: { value: startDate } });
68 | const calenderDate = document.querySelectorAll('.ant-picker-cell-selected')[0] as Element;
69 | userEvent.click(calenderDate);
70 | },
71 |
72 | chooseEndDate: (endDate: string) => {
73 | const element = this.getEndDateElement();
74 | fireEvent.mouseDown(element);
75 | fireEvent.change(element, { target: { value: endDate } });
76 | const calenderDates = document.querySelectorAll('.ant-picker-cell-selected');
77 | const calenderDate = calenderDates[calenderDates.length - 1] as Element;
78 | userEvent.click(calenderDate);
79 | },
80 | };
81 | }
82 |
--------------------------------------------------------------------------------
/src/components/DateRangePicker/DateRangePicker.tsx:
--------------------------------------------------------------------------------
1 | import React, { FunctionComponent } from 'react';
2 | import parse from 'date-fns/parse';
3 | import {} from '@src/services/storage';
4 | import Content from '@src/content.json';
5 | import { DatePicker, Typography } from 'antd';
6 | import styles from './DateRangePicker.scss';
7 | import dayjs, { Dayjs } from 'dayjs';
8 | const { Text } = Typography;
9 | import { DateUtils, IsraelDateFormat } from '@src/lib/utils/date';
10 | import { DatePickerTestIds } from '@src/components/dataTestIds';
11 | import { validateStartDate, validateEndDate } from '@src/validators/validators';
12 |
13 | export enum DateOptions {
14 | START_DATE = 'startDate',
15 | END_DATE = 'endDate',
16 | }
17 |
18 | interface IDateRangePickerProps {
19 | startDate: number;
20 | endDate: number;
21 | onDateChanged: (selectedDate: Date, dateOption: DateOptions) => void;
22 | }
23 |
24 | export const DateRangePicker: FunctionComponent = (props) => {
25 | const startDate = props.startDate ? dayjs(props.startDate) : undefined;
26 | const endDate = props.endDate ? dayjs(props.endDate) : undefined;
27 |
28 | const _onDateChanged = (dateString: string, dateOption: DateOptions) => {
29 | const selectedDate = parse(dateString, IsraelDateFormat, new Date());
30 | props.onDateChanged(selectedDate, dateOption);
31 | };
32 |
33 | const shouldDisabledDates = (date: Dayjs): boolean => {
34 | const startOfTodayDate = dayjs(new Date()).startOf('day').toDate();
35 | const startOfCompareDate = date.startOf('day').toDate();
36 | return DateUtils.isBefore(startOfCompareDate, startOfTodayDate);
37 | };
38 |
39 | return (
40 |
41 |
42 | {Content.startDateForAppointment.title}
43 | date && shouldDisabledDates(date)}
50 | status={!validateStartDate(props.startDate, props.endDate) ? 'error' : ''}
51 | onChange={(_, dateString) => _onDateChanged(dateString, DateOptions.START_DATE)}
52 | />
53 |
54 |
55 | {Content.endDateForAppointment.title}
56 | date && shouldDisabledDates(date)}
63 | status={!validateEndDate(props.endDate, props.startDate) ? 'error' : ''}
64 | onChange={(_, dateString) => _onDateChanged(dateString, DateOptions.END_DATE)}
65 | />
66 |
67 |
68 | );
69 | };
70 |
--------------------------------------------------------------------------------
/test/handlers.driver.ts:
--------------------------------------------------------------------------------
1 | import nock from 'nock';
2 | import { GetCalendarSlotTask, GetServiceIdByLocationIdTask, Priority, TaskType } from '@src/content-script/task';
3 | import { BaseURL, HttpService, PartialURLs } from '@src/lib/http';
4 | import { LocationServicesResponse, SearchAvailableSlotsResponse } from '@src/lib/api';
5 | import { PriorityQueue } from '@src/content-script/priority-queue';
6 | import { Handler as GetSlotForCalendar } from '@src/content-script/handlers/get-slot-for-calendar/get-slot-for-calendar';
7 | import { Handler as GetServiceByLocation } from '@src/content-script/handlers/get-service-by-location/get-service-by-location';
8 | import { Locations } from '@src/lib/locations';
9 | import { ServiceIds } from '@src/lib/constants';
10 | import { StorageService } from '@src/services/storage';
11 | import browser from 'webextension-polyfill';
12 | import { LocalStorageTestkit } from '@test/testkits/storage.testkit';
13 | import { BaseParams } from '@src/content-script/handlers';
14 |
15 | const storageTestkit = browser.storage.local as unknown as LocalStorageTestkit;
16 |
17 | export class HandlersDriver {
18 | private baseParams: BaseParams = {
19 | priorityQueue: new PriorityQueue(),
20 | slotsQueue: new PriorityQueue(),
21 | httpService: new HttpService(() => Promise.resolve()),
22 | storage: new StorageService(),
23 | };
24 |
25 | private getSlotForCalendarHandler = new GetSlotForCalendar(this.baseParams);
26 | private getServiceByLocationHandler = new GetServiceByLocation(this.baseParams, Locations);
27 |
28 | given = {
29 | storageValue: (val: Record) => storageTestkit.set(val),
30 | slotsByCalendarId: (calendarId: number, serviceId: number, response: SearchAvailableSlotsResponse) =>
31 | nock(BaseURL)
32 | .get(`/${PartialURLs.searchAvailableSlots}`)
33 | .query({
34 | CalendarId: calendarId,
35 | ServiceId: serviceId,
36 | dayPart: 0,
37 | })
38 | .reply(200, response),
39 | serviceByLocationId: (locationId: number, response: LocationServicesResponse) =>
40 | nock(BaseURL)
41 | .get(`/${PartialURLs.locationServices}`)
42 | .query({ locationId, serviceTypeId: ServiceIds.BiometricPassportAppointment })
43 | .reply(200, response),
44 | };
45 |
46 | when = {
47 | getServiceByLocation: (params: GetServiceIdByLocationIdTask['params']) => {
48 | return this.getServiceByLocationHandler.handle({
49 | params,
50 | type: TaskType.GetServiceIdByLocationId,
51 | priority: Priority.Low,
52 | });
53 | },
54 | getSlotForCalendar: (params: GetCalendarSlotTask['params']) =>
55 | this.getSlotForCalendarHandler.handle({
56 | params,
57 | type: TaskType.GetCalendarSlot,
58 | priority: Priority.Medium,
59 | }),
60 | };
61 |
62 | get = {
63 | queueTasks: () => this.baseParams.priorityQueue.toArray(),
64 | slotsQueue: () => this.baseParams.slotsQueue.toArray(),
65 | storageValue: (key: string) => storageTestkit.get(key),
66 | };
67 |
68 | reset = () => {
69 | nock.cleanAll();
70 | storageTestkit.clear();
71 | this.baseParams.priorityQueue.clear();
72 | };
73 | }
74 |
--------------------------------------------------------------------------------
/src/content-script/gamkenbot.ts:
--------------------------------------------------------------------------------
1 | import { AxiosError } from 'axios';
2 | import { Worker, WorkerConfig } from '@src/content-script/worker';
3 | import { StorageService } from '@src/services/storage';
4 | import { HttpService } from '@src/lib/http';
5 | import { VisitService } from '@src/lib/visit';
6 | import { ResponseStatus, SearchStatusType } from '@src/lib/internal-types';
7 | import { Locations } from '@src/lib/locations';
8 | import { dispatchSearchStatus } from '../lib/utils/status';
9 | import { errors as Content } from '@src/content.json';
10 |
11 | export class Gamkenbot {
12 | constructor(private readonly worker = new Worker(), private readonly storageService = new StorageService()) {}
13 |
14 | onRejectError = async (error: AxiosError): Promise => {
15 | const status = error?.response?.status;
16 | if (status === 401 || status === 403) {
17 | this.worker.stop();
18 | dispatchSearchStatus({ type: SearchStatusType.Error, message: Content.authError });
19 | await this.storageService.setLoggedIn(false);
20 | } else {
21 | dispatchSearchStatus({ type: SearchStatusType.Warning, message: Content.failingRequests });
22 | }
23 | };
24 |
25 | setLoggedIn = async (): Promise => {
26 | const httpService = new HttpService(this.onRejectError);
27 | try {
28 | const userInfo = await httpService.getUserInfo();
29 | await this.storageService.setLoggedIn(userInfo?.Results !== null);
30 | return true;
31 | } catch (e: unknown) {
32 | console.error(e);
33 | return false;
34 | }
35 | };
36 |
37 | startSearching = async (): Promise => {
38 | dispatchSearchStatus({ type: SearchStatusType.Waiting });
39 |
40 | const httpService = new HttpService(this.onRejectError);
41 | const info = await this.storageService.getUserMetadata();
42 | const visitService = new VisitService(httpService);
43 |
44 | if (!info) {
45 | dispatchSearchStatus({ type: SearchStatusType.Error, message: Content.noUserData });
46 | return false;
47 | }
48 |
49 | try {
50 | const preparedVisit = await visitService.prepare(info);
51 | if (preparedVisit.status === ResponseStatus.Success) {
52 | httpService.updateVisitToken(preparedVisit.data.visitToken);
53 | const locations = Locations.filter((location) => info.cities.includes(location.city));
54 | const config: WorkerConfig = {
55 | locations,
56 | userVisit: preparedVisit.data,
57 | dateRangeForAppointment: {
58 | startDate: info.startDate,
59 | endDate: info.endDate,
60 | },
61 | httpService: httpService,
62 | };
63 | await this.worker.start(config);
64 |
65 | return true;
66 | } else {
67 | throw new Error(`Error code ${preparedVisit.data.errorCode}`);
68 | }
69 | } catch (err) {
70 | console.error(err);
71 | dispatchSearchStatus({ type: SearchStatusType.Error, message: Content.questionsFailed });
72 | return false;
73 | }
74 | };
75 |
76 | stopSearching = async (): Promise => {
77 | try {
78 | await this.worker.stop();
79 | dispatchSearchStatus({ type: SearchStatusType.Stopped });
80 | return true;
81 | } catch (e: unknown) {
82 | console.error(e);
83 | return false;
84 | }
85 | };
86 | }
87 |
--------------------------------------------------------------------------------
/src/components/App/App.test.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { PageBaseDriver } from '@test/RTL/RTLPageBaseDriver';
3 | import { IsraelDateFormat } from '@src/lib/utils';
4 | import format from 'date-fns/format';
5 | import addDays from 'date-fns/addDays';
6 |
7 | const todayDate = format(new Date(), IsraelDateFormat);
8 | const tomorrowDate = format(addDays(new Date(), 1), IsraelDateFormat);
9 |
10 | describe('Gamkenbot App', () => {
11 | const driver = new PageBaseDriver();
12 |
13 | beforeEach(() => driver.mount());
14 |
15 | it('renders without crashing', async () => {
16 | expect(driver.get.disconnectedStatus()).toBeInTheDocument();
17 | });
18 |
19 | it('should enable start button when user fill all required fields and check consent', async () => {
20 | driver.userMetadataDriver.when.fillUserId('123456782');
21 | expect(driver.get.startButton()).toBeDisabled();
22 |
23 | driver.userMetadataDriver.when.fillPhoneNumber('0507277474');
24 | expect(driver.get.startButton()).toBeDisabled();
25 |
26 | driver.userMetadataDriver.when.selectCities(['תל אביב', 'גבעתיים']);
27 | expect(driver.get.startButton()).toBeDisabled();
28 |
29 | driver.when.clickConsent();
30 | expect(driver.get.startButton()).toBeEnabled();
31 | });
32 |
33 | describe('Handle invalid metadata', () => {
34 | beforeEach(() => {
35 | driver.given.userMetadata({
36 | userId: '123456782',
37 | phoneNumber: '0507277474',
38 | cities: ['תל אביב', 'גבעתיים'],
39 | startDate: todayDate,
40 | endDate: tomorrowDate,
41 | });
42 | driver.when.clickConsent();
43 | });
44 | it('should disabled start button when user fill invalid phone number', () => {
45 | expect(driver.get.startButton()).toBeEnabled();
46 |
47 | // Invalid phone number
48 | driver.userMetadataDriver.when.fillPhoneNumber('123abs');
49 | expect(driver.userMetadataDriver.get.phoneNumber()).toEqual('123abs');
50 |
51 | expect(driver.get.startButton()).toBeDisabled();
52 | });
53 |
54 | it('should disabled start button when user fill invalid ID', () => {
55 | expect(driver.get.startButton()).toBeEnabled();
56 |
57 | // Invalid ID
58 | driver.userMetadataDriver.when.fillUserId('050741');
59 | expect(driver.userMetadataDriver.get.userId()).toEqual('050741');
60 |
61 | expect(driver.get.startButton()).toBeDisabled();
62 | });
63 |
64 | it('should disabled start button when user choose more than max cities ', () => {
65 | expect(driver.get.startButton()).toBeEnabled();
66 |
67 | // Invalid cities - add 3 more (5 in total)
68 | driver.userMetadataDriver.when.selectCities(['חדרה', 'נתניה', 'כפר סבא']);
69 |
70 | expect(driver.get.startButton()).toBeDisabled();
71 | });
72 |
73 | it('should disabled start button when user choose invalid dates', () => {
74 | expect(driver.get.startButton()).toBeEnabled();
75 |
76 | // Invalid dates number
77 | driver.userMetadataDriver.when.chooseStartDate(tomorrowDate);
78 | driver.userMetadataDriver.when.chooseEndDate(todayDate);
79 |
80 | expect(driver.userMetadataDriver.get.startDate()).toEqual(tomorrowDate);
81 | expect(driver.userMetadataDriver.get.endDate()).toEqual(todayDate);
82 |
83 | expect(driver.get.startButton()).toBeDisabled();
84 | });
85 |
86 | it('should disabled start button when user unchecked consent', () => {
87 | expect(driver.get.startButton()).toBeEnabled();
88 |
89 | // unchecked consent
90 | driver.when.clickConsent();
91 |
92 | expect(driver.get.startButton()).toBeDisabled();
93 | });
94 | });
95 | });
96 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "gamkenbot",
3 | "version": "2.0.0",
4 | "description": "Web Extension starter kit built with React, TypeScript, Tailwind CSS, EsLint, Prettier & Webpack",
5 | "scripts": {
6 | "build": "webpack --config webpack.prod.js",
7 | "dev": "webpack -w --config webpack.dev.js",
8 | "test": "jest",
9 | "lint:check": "eslint -c ./.eslintrc.js \"src/**/*.ts*\"",
10 | "lint": "npm run lint:check -- --fix",
11 | "lint:fix": "eslint --fix -c ./.eslintrc.js \"src/**/*.ts*\"",
12 | "prettify:check": "prettier \"src/**/*.ts*\"",
13 | "prettify": "prettier --write \"src/**/*.ts*\""
14 | },
15 | "repository": {
16 | "type": "git",
17 | "url": "git+https://github.com/shilomagen/passport-extension.git"
18 | },
19 | "keywords": [
20 | "react",
21 | "typescript",
22 | "chrome",
23 | "extension",
24 | "boilerplate"
25 | ],
26 | "author": "Shilo Magen",
27 | "license": "MIT",
28 | "bugs": {
29 | "url": "https://github.com/shilomagen/passport-extension/issues"
30 | },
31 | "homepage": "https://github.com/shilomagen/passport-extension#readme",
32 | "devDependencies": {
33 | "@babel/core": "^7.11.6",
34 | "@babel/preset-env": "^7.11.5",
35 | "@babel/preset-typescript": "^7.10.4",
36 | "@svgr/webpack": "^6.5.1",
37 | "@testing-library/jest-dom": "^5.16.5",
38 | "@testing-library/react": "^14.0.0",
39 | "@testing-library/user-event": "^13.5.0",
40 | "@types/chrome": "^0.0.124",
41 | "@types/jest": "^29.5.0",
42 | "@types/lodash.debounce": "^4.0.7",
43 | "@types/lodash.merge": "^4.6.7",
44 | "@types/node": "^14.11.8",
45 | "@types/react": "^18.0.2",
46 | "@types/react-dom": "^18.0.2",
47 | "@types/react-test-renderer": "^17.0.1",
48 | "@types/uuid": "^9.0.1",
49 | "@types/webextension-polyfill": "^0.9.0",
50 | "@typescript-eslint/eslint-plugin": "^4.4.1",
51 | "@typescript-eslint/parser": "^4.4.1",
52 | "autoprefixer": "^10.4.1",
53 | "babel-core": "^6.26.3",
54 | "babel-jest": "^29.5.0",
55 | "babel-loader": "^8.1.0",
56 | "copy-webpack-plugin": "^11.0.0",
57 | "css-loader": "^4.3.0",
58 | "eslint": "^7.11.0",
59 | "eslint-config-prettier": "^6.12.0",
60 | "eslint-plugin-prettier": "^3.1.4",
61 | "eslint-plugin-react": "^7.21.4",
62 | "html-webpack-plugin": "^5.5.0",
63 | "jest": "^29.5.0",
64 | "jest-css-modules": "^2.1.0",
65 | "jest-environment-jsdom": "^29.5.0",
66 | "lodash.merge": "^4.6.2",
67 | "mini-css-extract-plugin": "^2.7.5",
68 | "nock": "^13.3.0",
69 | "npm": "^9.6.2",
70 | "postcss": "^8.4.21",
71 | "postcss-loader": "^6.2.1",
72 | "postcss-preset-env": "^8.0.1",
73 | "prettier": "^2.1.2",
74 | "react-test-renderer": "^18.2.0",
75 | "sass": "^1.59.3",
76 | "sass-loader": "^13.2.1",
77 | "style-loader": "^3.3.2",
78 | "ts-jest": "^29.1.0",
79 | "ts-loader": "^9.4.2",
80 | "typescript": "^5.0.3",
81 | "webpack": "^5.77.0",
82 | "webpack-cli": "^4.9.1",
83 | "webpack-merge": "^5.8.0"
84 | },
85 | "dependencies": {
86 | "@datastructures-js/priority-queue": "^6.2.0",
87 | "antd": "^5.3.2",
88 | "axios": "^1.3.4",
89 | "date-fns": "^2.29.3",
90 | "dayjs": "^1.11.7",
91 | "interval-plus": "^1.0.2",
92 | "lodash.debounce": "^4.0.8",
93 | "react": "^18.2.0",
94 | "react-dom": "^18.2.0",
95 | "stylis": "^4.1.3",
96 | "stylis-plugin-rtl": "^2.1.1",
97 | "ts-essentials": "^9.3.1",
98 | "ts-pattern": "^4.2.2",
99 | "uuid": "^9.0.0",
100 | "webextension-polyfill": "^0.9.0"
101 | }
102 | }
--------------------------------------------------------------------------------
/src/lib/question-resolver/question-resolver.test.ts:
--------------------------------------------------------------------------------
1 | import { QuestionResolver } from './question-resolver';
2 | import { PrepareVisitData } from '@src/lib/api';
3 | import { ErrorCode } from '@src/lib/constants';
4 |
5 | const MockPrepareVisitData: PrepareVisitData = {
6 | PreparedVisitId: 86742504,
7 | UserId: 183585562,
8 | ServiceId: 2247,
9 | ServiceTypeId: 156,
10 | OrganizationId: null,
11 | DateCreated: new Date('2022-05-02T16:42:35.483'),
12 | PreparedVisitToken: 'b67ae922-e9c5-4551-9993-e5d5fd7d95a1',
13 | QuestionnaireItem: {
14 | QuestionnaireItemId: 200,
15 | ServiceTypeId: 156,
16 | OrganizationId: null,
17 | QuestionId: 114,
18 | Position: 2,
19 | IsActive: true,
20 | Question: {
21 | QuestionId: 114,
22 | OrganizationId: 56,
23 | IsActive: true,
24 | Title: 'הכנסת מספר טלפון נייד',
25 | Text: 'אנא הקלד מספר טלפון נייד\nPlease type cellphone No.',
26 | Description: 'הכנס מספר נייד עם תחילית 05\nEnter cellphone No. begins with 05.',
27 | CustomErrorText: 'הקלד ספרות בלבד',
28 | Type: 0,
29 | MappedTo: 3,
30 | MappedToCustomerCustomPropertyId: 0,
31 | MappedToProcessCustomPropertyId: 0,
32 | MappedToPropertyName: 'מספר נייד מה-Central',
33 | Required: true,
34 | ValidateAnswerOnQFlow: true,
35 | ValidateAnswerOnClient: true,
36 | ValidationExpression: '[0-9]{10,10}',
37 | AskOncePerCustomer: false,
38 | QuestionKey: 'PHONE_KEYPAD',
39 | Answers: [],
40 | ExtRef: '',
41 | SaveAnswerWithAppointment: false,
42 | },
43 | },
44 | NavigationState: {
45 | State: null,
46 | OrganizationId: 56,
47 | ServiceTypeId: 0,
48 | LocationId: 0,
49 | ServiceId: 0,
50 | QflowServiceId: 0,
51 | },
52 | IsUserIdentify: null,
53 | };
54 |
55 | describe('[Question Resolver]', () => {
56 | test('should return null if there is no validation error', () => {
57 | const prepareVisitDataWithNoError: PrepareVisitData = {
58 | ...MockPrepareVisitData,
59 | Validation: undefined,
60 | };
61 | expect(QuestionResolver.hasErrors(prepareVisitDataWithNoError)).toBeNull();
62 | });
63 | test('should return phone error code', () => {
64 | const prepareVisitDataWithPhoneError: PrepareVisitData = {
65 | ...MockPrepareVisitData,
66 | Validation: {
67 | Messages: [
68 | {
69 | Message: 'מספר הטלפון אינו תקין, הקלד שנית',
70 | Type: 'Error',
71 | },
72 | ],
73 | },
74 | };
75 | expect(QuestionResolver.hasErrors(prepareVisitDataWithPhoneError)).toBe(ErrorCode.PhoneNumberNotValid);
76 | });
77 |
78 | test('should return id error code', () => {
79 | const prepareVisitDataWithIdError: PrepareVisitData = {
80 | ...MockPrepareVisitData,
81 | Validation: {
82 | Messages: [
83 | {
84 | Message: 'תעודת הזהות',
85 | Type: 'Error',
86 | },
87 | ],
88 | },
89 | };
90 | expect(QuestionResolver.hasErrors(prepareVisitDataWithIdError)).toBe(ErrorCode.IdNotValid);
91 | });
92 |
93 | test('should return general error code if we cant find an error', () => {
94 | const prepareVisitDataWithUnknownError: PrepareVisitData = {
95 | ...MockPrepareVisitData,
96 | Validation: {
97 | Messages: [
98 | {
99 | Message: 'unknown',
100 | Type: 'Error',
101 | },
102 | ],
103 | },
104 | };
105 | expect(QuestionResolver.hasErrors(prepareVisitDataWithUnknownError)).toBe(ErrorCode.General);
106 | });
107 | });
108 |
--------------------------------------------------------------------------------
/src/lib/utils/date.test.ts:
--------------------------------------------------------------------------------
1 | import { DateUtils } from './date';
2 |
3 | describe('[Date Utils]', () => {
4 | describe('timeSinceMidnightToHour', () => {
5 | test('should pad number with 0 if it has single digit in hour', () => {
6 | const timeFromMidnight = 520;
7 | expect(DateUtils.timeSinceMidnightToHour(timeFromMidnight).split(':')[0]).toBe('08');
8 | });
9 | test('should pad number with 0 if it has single digit in minutes', () => {
10 | const timeFromMidnight = 421;
11 | expect(DateUtils.timeSinceMidnightToHour(timeFromMidnight).split(':')[1]).toBe('01');
12 | });
13 | });
14 |
15 | describe('isDateInRange', () => {
16 | const startDate = new Date('2023-04-10');
17 | const endDate = new Date('2023-04-20');
18 | test('should return true if current date is within date range', () => {
19 | const currentDate = '2023-04-15';
20 | expect(DateUtils.isDateInRange(currentDate, startDate, endDate)).toBe(true);
21 | });
22 | test('should return true if current date is equal to start date', () => {
23 | const currentDate = '2023-04-10';
24 | expect(DateUtils.isDateInRange(currentDate, startDate, endDate)).toBe(true);
25 | });
26 | test('should return true if current date is equal to end date', () => {
27 | const currentDate = '2023-04-20';
28 | expect(DateUtils.isDateInRange(currentDate, startDate, endDate)).toBe(true);
29 | });
30 | test('should return false if current date is before start date', () => {
31 | const currentDate = '2023-04-05';
32 | expect(DateUtils.isDateInRange(currentDate, startDate, endDate)).toBe(false);
33 | });
34 | test('should return false if current date is after end date', () => {
35 | const startDate = new Date('2023-04-10');
36 | const endDate = new Date('2023-04-20');
37 | const currentDate = '2023-04-25';
38 | expect(DateUtils.isDateInRange(currentDate, startDate, endDate)).toBe(false);
39 | });
40 | });
41 |
42 | describe('isBefore', () => {
43 | test('should return true if date is before the compare date', () => {
44 | const currentDate = new Date('2023-04-14');
45 | const compareDate = new Date('2023-04-15');
46 | expect(DateUtils.isBefore(currentDate, compareDate)).toBe(true);
47 | });
48 |
49 | test('should return false if current date is after the compare date', () => {
50 | const currentDate = new Date('2023-04-16');
51 | const compareDate = new Date('2023-04-15');
52 | expect(DateUtils.isBefore(currentDate, compareDate)).toBe(false);
53 | });
54 |
55 | test('should return false if current date is equal to the compare date', () => {
56 | const currentDate = new Date('2023-04-15');
57 | const compareDate = new Date('2023-04-15');
58 | expect(DateUtils.isBefore(currentDate, compareDate)).toBe(false);
59 | });
60 | });
61 |
62 | describe('isAfter', () => {
63 | test('should return true if date is after the compare date', () => {
64 | const currentDate = new Date('2023-04-20');
65 | const compareDate = new Date('2023-04-15');
66 | expect(DateUtils.isAfter(currentDate, compareDate)).toBe(true);
67 | });
68 | test('should return false if current date is before the compare date', () => {
69 | const currentDate = new Date('2023-04-10');
70 | const compareDate = new Date('2023-04-15');
71 | expect(DateUtils.isAfter(currentDate, compareDate)).toBe(false);
72 | });
73 | test('should return false if current date is equal to the compare date', () => {
74 | const currentDate = new Date('2023-04-15');
75 | const compareDate = new Date('2023-04-15');
76 | expect(DateUtils.isAfter(currentDate, compareDate)).toBe(false);
77 | });
78 | });
79 | });
80 |
--------------------------------------------------------------------------------
/src/content-script/handlers/get-service-by-location/get-service-by-location.test.ts:
--------------------------------------------------------------------------------
1 | import { HandlersDriver } from '@test/handlers.driver';
2 | import { Locations } from '@src/lib/locations';
3 | import { LocationServicesResultFixtures } from '@test/fixtures/location-service.fixture';
4 | import { LocationServicesResponseFixtures } from '@test/fixtures/location-services-response.fixture';
5 | import { GetServiceCalendarTask, Priority, TaskType } from '@src/content-script/task';
6 | import { DAY, LOCATION_PREFIX } from '@src/services/storage';
7 | import { toService } from '@src/lib/mappers';
8 | import { Service } from '@src/lib/internal-types';
9 |
10 | describe('[GetServiceByLocation Handler]', () => {
11 | const driver = new HandlersDriver();
12 | const defaultLocation = Locations[0];
13 |
14 | beforeEach(() => {
15 | driver.reset();
16 | jest.clearAllMocks();
17 | });
18 |
19 | test('should fetch services if not in cache', async () => {
20 | const locationService = LocationServicesResultFixtures.valid({ LocationId: defaultLocation.id });
21 | const response = LocationServicesResponseFixtures.valid({ Results: [locationService] });
22 | const scope = driver.given.serviceByLocationId(defaultLocation.id, response);
23 | await driver.when.getServiceByLocation({ locationId: defaultLocation.id });
24 | expect(scope.isDone()).toBe(true);
25 | expect(driver.get.queueTasks()).toContainEqual({
26 | params: {
27 | location: defaultLocation,
28 | serviceId: locationService.serviceId,
29 | },
30 | type: TaskType.GetServiceCalendar,
31 | priority: Priority.Medium,
32 | });
33 | });
34 |
35 | test('should not fetch and get from cache if service exist in cache', async () => {
36 | const locationService = LocationServicesResultFixtures.valid({ LocationId: defaultLocation.id });
37 | const response = LocationServicesResponseFixtures.valid({ Results: [locationService] });
38 | await driver.given.storageValue({
39 | [LOCATION_PREFIX + defaultLocation.id]: {
40 | expiry: Date.now() + DAY * 3,
41 | services: [toService(locationService)],
42 | },
43 | });
44 | const scope = driver.given.serviceByLocationId(defaultLocation.id, response);
45 | await driver.when.getServiceByLocation({ locationId: defaultLocation.id });
46 | expect(scope.isDone()).toBe(false);
47 | });
48 |
49 | test('should add services to cache after first call', async () => {
50 | const locationService = LocationServicesResultFixtures.valid({ LocationId: defaultLocation.id });
51 | const response = LocationServicesResponseFixtures.valid({ Results: [locationService] });
52 | const expectedService = toService(locationService);
53 | driver.given.serviceByLocationId(defaultLocation.id, response);
54 | await driver.when.getServiceByLocation({ locationId: defaultLocation.id });
55 | const cacheKey = LOCATION_PREFIX + defaultLocation.id;
56 | const cache = await driver.get.storageValue(cacheKey);
57 | expect(cache[cacheKey].services).toContainEqual(expectedService);
58 | });
59 |
60 | test('should remove cache and fetch services if TTL passed', async () => {
61 | const dateNow = Date.now();
62 | const locationService = LocationServicesResultFixtures.valid({ LocationId: defaultLocation.id });
63 | const response = LocationServicesResponseFixtures.valid({ Results: [locationService] });
64 | await driver.given.storageValue({
65 | [LOCATION_PREFIX + defaultLocation.id]: { expiry: dateNow, services: [toService(locationService)] },
66 | });
67 | Date.now = jest.fn().mockResolvedValueOnce(dateNow + DAY * 4);
68 | const scope = driver.given.serviceByLocationId(defaultLocation.id, response);
69 | await driver.when.getServiceByLocation({ locationId: defaultLocation.id });
70 | expect(scope.isDone()).toBe(true);
71 | });
72 | });
73 |
--------------------------------------------------------------------------------
/src/components/DateRangePicker/DateRangePicker.test.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { IsraelDateFormat } from '@src/lib/utils';
3 | import { PageBaseDriver } from '@test/RTL/RTLPageBaseDriver';
4 | import subDays from 'date-fns/subDays';
5 | import addDays from 'date-fns/addDays';
6 | import format from 'date-fns/format';
7 |
8 | describe('Date Range Picker', () => {
9 | const driver = new PageBaseDriver();
10 | const yesterdayDate = format(subDays(new Date(), 1), IsraelDateFormat);
11 | const todayDate = format(new Date(), IsraelDateFormat);
12 | const tomorrowDate = format(addDays(new Date(), 1), IsraelDateFormat);
13 | const defaultEndDate = format(addDays(new Date(), 14), IsraelDateFormat);
14 |
15 | beforeEach(() => driver.mount());
16 |
17 | describe('Default start and end dates values', () => {
18 | it('should init the start date to today`s date', async () => {
19 | const startDate = driver.userMetadataDriver.get.startDate();
20 | expect(startDate).toEqual(todayDate);
21 | });
22 |
23 | it('should init the end date to 14 days from today', async () => {
24 | const endDate = driver.userMetadataDriver.get.endDate();
25 | expect(endDate).toEqual(defaultEndDate);
26 | });
27 | });
28 |
29 | describe('Date Selection', () => {
30 | it('should select the start date to be tomorrow', async () => {
31 | const startDate = driver.userMetadataDriver.get.startDate();
32 | expect(startDate).toEqual(todayDate);
33 |
34 | driver.userMetadataDriver.when.chooseStartDate(tomorrowDate);
35 | const updatedStartDate = driver.userMetadataDriver.get.startDate();
36 | expect(updatedStartDate).toBe(tomorrowDate);
37 | });
38 |
39 | it('should select end date that is exactly 7 days from today', async () => {
40 | const endDate = driver.userMetadataDriver.get.endDate();
41 | expect(endDate).toEqual(defaultEndDate);
42 |
43 | const newEndDate = format(addDays(new Date(), 7), IsraelDateFormat);
44 | driver.userMetadataDriver.when.chooseEndDate(newEndDate);
45 |
46 | const updatedEndDate = driver.userMetadataDriver.get.endDate();
47 | expect(updatedEndDate).toBe(newEndDate);
48 | });
49 |
50 | it('should allow to select same end and start date', async () => {
51 | const endDate = driver.userMetadataDriver.get.endDate();
52 | const startDate = driver.userMetadataDriver.get.startDate();
53 | expect(startDate).toEqual(todayDate);
54 | expect(endDate).toEqual(defaultEndDate);
55 |
56 | const newDate = format(addDays(new Date(), 7), IsraelDateFormat);
57 | driver.userMetadataDriver.when.chooseStartDate(newDate);
58 | driver.userMetadataDriver.when.chooseEndDate(newDate);
59 |
60 | const updatedStartDate = driver.userMetadataDriver.get.startDate();
61 | const updatedEndDate = driver.userMetadataDriver.get.endDate();
62 |
63 | expect(updatedEndDate).toBe(newDate);
64 | expect(updatedStartDate).toBe(newDate);
65 | });
66 | });
67 |
68 | describe('Invalid Date Selection', () => {
69 | it('should not allow choosing a date in the past for start date', async () => {
70 | const startDate = driver.userMetadataDriver.get.startDate();
71 | expect(startDate).toEqual(todayDate);
72 |
73 | // Try to select a disabled date
74 | driver.userMetadataDriver.when.chooseStartDate(yesterdayDate);
75 |
76 | // start date should not change
77 | const currentStartDate = driver.userMetadataDriver.get.startDate();
78 | expect(currentStartDate).toBe(todayDate);
79 | });
80 |
81 | it('should not allow choosing a date in the past for end date date', async () => {
82 | expect(driver.userMetadataDriver.get.endDate()).toEqual(defaultEndDate);
83 |
84 | // Try to select a disabled date
85 | driver.userMetadataDriver.when.chooseEndDate(yesterdayDate);
86 |
87 | // start date should not change
88 | expect(driver.userMetadataDriver.get.endDate()).toEqual(defaultEndDate);
89 | });
90 | });
91 | });
92 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # [Gamkenbot](https://chrome.google.com/webstore/detail/gamkenbot/edgflihadcmpgaicadncdiigconmhhnn) · [](https://github.com/shilomagen/passport-extension/blob/main/LICENSE) [](https://www.npmjs.com/package/react) [](https://github.com/shilomagen/passport-extension/actions/workflows/test.yml) [](https://github.com/shilomagen/passport-extension/blob/main/README.md#contributing)
2 |
3 |
4 |
5 |
6 |
7 |
8 | # Gamkenbot
9 | > Gamkenbot is a Chrome extension that automates appointment finding and scheduling on the myVisit
10 | > website. Created by Shilo Magen, it is built using React, TypeScript, Tailwind CSS, EsLint,
11 | > Prettier, and Webpack.
12 |
13 | ## Usage
14 |
15 | Gamkenbot will automate the process of finding and scheduling appointments on the myVisit website.
16 | Simply open the myVisit website and let Gamkenbot handle the rest.
17 |
18 | ## Installation
19 |
20 | To install Gamkenbot, follow these steps:
21 |
22 | 1. Clone the repository:
23 |
24 | ```bash
25 | git clone https://github.com/shilomagen/passport-extension.git
26 | ```
27 |
28 | 2. Install dependencies:
29 |
30 | ```bash
31 | npm install
32 | ```
33 |
34 | 3. Build the extension:
35 |
36 | ```bash
37 | npm run build
38 | ```
39 |
40 | 4. Open the Chrome browser and navigate to chrome://extensions/.
41 | 5. Toggle on "Developer mode" in the top right corner.
42 | 6. Click on "Load unpacked" in the top left corner.
43 | 7. Select the dist folder inside the repository directory.
44 | 8. The extension should now be installed and ready to use.
45 |
46 | ### Gamkenbot Extension
47 |
48 | #### Popup `src/popup/popup.tsx`
49 |
50 | In Chrome extension development, a "popup" is a small window that appears when a user clicks on the
51 | extension icon in the browser toolbar. The popup can display information or provide functionality
52 | related to the extension. The popup window can be a basic HTML file or a more complex React, Vue, or
53 | Angular app.
54 |
55 | #### Content Script `src/content-script/content-script.ts`
56 |
57 | A "content script" is a JavaScript file that runs in the context of a web page, similar to a browser
58 | extension. Content scripts can manipulate the DOM of a web page, modify its appearance, or interact
59 | with the page's JavaScript API. In Chrome extension development, content scripts are located in a
60 | separate folder named "content_scripts".
61 |
62 | #### Background Page `src/background-page.ts`
63 |
64 | In Chrome extension development, a "background page" is a JavaScript file that runs in the
65 | background of the browser and can listen for events, perform actions, and communicate with other
66 | components of the extension. The background page runs separately from the content scripts and popup
67 | window, allowing it to execute long-running processes or perform actions even when the user is not
68 | actively interacting with the extension. The background page is defined in the "background" field of
69 | the extension's manifest file.
70 |
71 | ### Contributing
72 |
73 | If you would like to contribute to Gamkenbot, please follow these steps:
74 |
75 | 1. Fork the repository.
76 | 2. Create a new branch:
77 |
78 | ```bash
79 | git checkout -b new-branch-name
80 | ```
81 |
82 | 3. Make your changes and commit them:
83 |
84 | ```bash
85 | git commit -m "Your commit message"
86 | ```
87 |
88 | 4. Push your changes:
89 |
90 | ```bash
91 | git push origin new-branch-name
92 | ```
93 |
94 | 5. Create a pull request from your branch to the main branch of the original repository.
95 | 6. Wait for feedback and approval.
96 |
97 | ### License
98 |
99 | This project is licensed under the MIT License. See the LICENSE file for details.
100 |
--------------------------------------------------------------------------------
/src/assets/gamkenbot.svg:
--------------------------------------------------------------------------------
1 |
2 |
37 |
--------------------------------------------------------------------------------
/src/services/storage.ts:
--------------------------------------------------------------------------------
1 | import browser from 'webextension-polyfill';
2 | import { v4 as uuid } from 'uuid';
3 | import { SearchStatus, SearchStatusType, Service } from '@src/lib/internal-types';
4 |
5 | export interface UserMetadata {
6 | id: string;
7 | phone: string;
8 | cities: string[];
9 | startDate: number;
10 | endDate: number;
11 | }
12 |
13 | const LAST_EXTENSION_VERSION_KEY = 'lastVersion';
14 | const USER_METADATA_KEY = 'userMetadata';
15 | const USER_LOGGED_IN = 'userLoggedIn';
16 | const USER_CONSENT = 'userConsent';
17 | const USER_SEARCH_STATUS = 'userSearchStatus';
18 | const USER_ID = 'userId';
19 |
20 | export const LOCATION_PREFIX = 'location_';
21 |
22 | export const HOUR = 1000 * 60 * 60;
23 | export const DAY = HOUR * 24;
24 |
25 | export class StorageService {
26 | getLastExtensionVersion(): Promise {
27 | return browser.storage.local.get(LAST_EXTENSION_VERSION_KEY).then((res) => res[LAST_EXTENSION_VERSION_KEY] ?? null);
28 | }
29 |
30 | updateLastExtensionVersion(): Promise {
31 | return browser.storage.local.set({ [LAST_EXTENSION_VERSION_KEY]: browser.runtime.getManifest().version });
32 | }
33 |
34 | setUserMetadata(metadata: UserMetadata): Promise {
35 | return browser.storage.local.set({ [USER_METADATA_KEY]: metadata });
36 | }
37 |
38 | async getUserMetadata(): Promise {
39 | return browser.storage.local.get(USER_METADATA_KEY).then((res) => res[USER_METADATA_KEY] ?? null);
40 | }
41 |
42 | setLoggedIn(loggedIn: boolean): Promise {
43 | return browser.storage.local.set({
44 | [USER_LOGGED_IN]: {
45 | loggedIn,
46 | expiry: Date.now() + HOUR,
47 | },
48 | });
49 | }
50 |
51 | async getLoggedIn(): Promise {
52 | const maybeLoggedIn = await browser.storage.local.get(USER_LOGGED_IN);
53 | if (maybeLoggedIn[USER_LOGGED_IN]) {
54 | return this.getLoggedInResult(maybeLoggedIn[USER_LOGGED_IN]);
55 | } else {
56 | return false;
57 | }
58 | }
59 |
60 | private async getLoggedInResult(entry: { loggedIn: boolean; expiry: number }): Promise {
61 | const { loggedIn, expiry } = entry;
62 |
63 | return expiry > Date.now() ? loggedIn : browser.storage.local.remove(USER_LOGGED_IN).then(() => false);
64 | }
65 |
66 | onLoggedInChange(callback: (loggedIn: boolean) => void): () => void {
67 | const listener = async (changes: Record) => {
68 | if (changes[USER_LOGGED_IN]) {
69 | const result = await this.getLoggedInResult(changes[USER_LOGGED_IN].newValue);
70 | callback(result);
71 | }
72 | };
73 |
74 | browser.storage.onChanged.addListener(listener);
75 |
76 | return () => browser.storage.onChanged.removeListener(listener);
77 | }
78 |
79 | getConsent(): Promise {
80 | return browser.storage.local.get(USER_CONSENT).then((res) => res[USER_CONSENT] ?? false);
81 | }
82 |
83 | setConsent(consent: boolean): Promise {
84 | return browser.storage.local.set({ [USER_CONSENT]: consent });
85 | }
86 |
87 | setSearchStatus(status: SearchStatus): Promise {
88 | return browser.storage.local.set({ [USER_SEARCH_STATUS]: status });
89 | }
90 |
91 | getSearchStatus(): Promise {
92 | const defaultStatus: SearchStatus = { type: SearchStatusType.Stopped };
93 |
94 | return browser.storage.local.get(USER_SEARCH_STATUS).then((res) => res[USER_SEARCH_STATUS] ?? defaultStatus);
95 | }
96 |
97 | async getUserId(): Promise {
98 | const maybeUserId = await browser.storage.local.get(USER_ID).then((res) => res[USER_ID]);
99 | if (maybeUserId) {
100 | return maybeUserId || '';
101 | } else {
102 | const userId = uuid();
103 | await this.setUserId(userId);
104 | return userId;
105 | }
106 | }
107 |
108 | setUserId(id: string): Promise {
109 | return browser.storage.local.set({ [USER_ID]: id });
110 | }
111 |
112 | async getServiceIdByLocationId(locationId: number): Promise {
113 | const servicesKey = LOCATION_PREFIX + locationId;
114 | const maybeServices = await browser.storage.local.get(servicesKey);
115 | if (maybeServices[servicesKey]) {
116 | const { expiry, services } = maybeServices[LOCATION_PREFIX + locationId];
117 | return expiry > Date.now()
118 | ? services
119 | : browser.storage.local.remove(LOCATION_PREFIX + locationId).then(() => null);
120 | } else {
121 | return null;
122 | }
123 | }
124 |
125 | setServiceIdByLocationId(locationId: number, services: Service[]): Promise {
126 | return browser.storage.local.set({
127 | [LOCATION_PREFIX + locationId]: {
128 | expiry: Date.now() + DAY * 3,
129 | services,
130 | },
131 | });
132 | }
133 | }
134 |
--------------------------------------------------------------------------------
/src/content-script/worker.ts:
--------------------------------------------------------------------------------
1 | import { HttpService } from '@src/lib/http';
2 | import { Location, SearchStatusType, UserVisitSuccessData } from '@src/lib/internal-types';
3 | import { Handler as GetServiceByLocationHandler } from '@src/content-script/handlers/get-service-by-location/get-service-by-location';
4 | import { Handler as GetServiceCalendarHandler } from '@src/content-script/handlers/get-service-calendar';
5 | import { Handler as GetSlotForCalendarHandler } from '@src/content-script/handlers/get-slot-for-calendar/get-slot-for-calendar';
6 | import { Handler as Scheduler } from '@src/content-script/handlers/scheduler';
7 | import { match } from 'ts-pattern';
8 | import { PriorityQueue } from '@src/content-script/priority-queue';
9 | import {
10 | GetServiceIdByLocationIdTask,
11 | Priority,
12 | ScheduleAppointmentTask,
13 | Task,
14 | TaskType,
15 | } from '@src/content-script/task';
16 | import { BaseParams } from '@src/content-script/handlers';
17 | import { StorageService } from '@src/services/storage';
18 | import setRandomInterval, { RandomIntervalClear } from '@src/utils/random-interval';
19 | import { DateRange } from '@src/lib/internal-types';
20 | import { dispatchSearchStatus } from '../lib/utils/status';
21 |
22 | export interface WorkerConfig {
23 | locations: Location[];
24 | userVisit: UserVisitSuccessData;
25 | dateRangeForAppointment: DateRange;
26 | httpService: HttpService;
27 | }
28 |
29 | export class Worker {
30 | private clearFunc: RandomIntervalClear | null = null;
31 | private slotsInterval: NodeJS.Timer | null = null;
32 | private settingAppointment = false;
33 |
34 | constructor(
35 | private readonly priorityQueue: PriorityQueue = new PriorityQueue(),
36 | private readonly slotsQueue: PriorityQueue = new PriorityQueue(),
37 | private readonly storageService = new StorageService(),
38 | ) {}
39 |
40 | fillQueue(locations: Location[]): void {
41 | locations.forEach((location) => {
42 | const task: GetServiceIdByLocationIdTask = {
43 | type: TaskType.GetServiceIdByLocationId,
44 | params: { locationId: location.id },
45 | priority: Priority.Low,
46 | };
47 | this.priorityQueue.enqueue(task);
48 | });
49 | }
50 |
51 | tick = (config: WorkerConfig): void => {
52 | if (!this.priorityQueue.isEmpty()) {
53 | const task = this.priorityQueue.dequeue();
54 | this.handle(task, config).catch((err) => console.error(err));
55 | } else {
56 | this.fillQueue(config.locations);
57 | }
58 | };
59 |
60 | schedule = (config: WorkerConfig): void => {
61 | if (!this.slotsQueue.isEmpty()) {
62 | const task = this.slotsQueue.dequeue();
63 | this.handle(task, config).catch((err) => console.error(err));
64 | }
65 | };
66 |
67 | start = async (config: WorkerConfig): Promise => {
68 | this.clearFunc = setRandomInterval(() => this.tick(config), 500, 1500);
69 | this.slotsInterval = setInterval(() => this.schedule(config), 300);
70 | await dispatchSearchStatus({ type: SearchStatusType.Started });
71 | };
72 |
73 | stop = (): void => {
74 | if (this.clearFunc) {
75 | this.clearFunc.clear();
76 | this.clearFunc = null;
77 | this.priorityQueue.clear();
78 | }
79 |
80 | if (this.slotsInterval) {
81 | clearInterval(this.slotsInterval);
82 | this.slotsInterval = null;
83 | this.slotsQueue.clear();
84 | }
85 | };
86 |
87 | async scheduleAppointment(
88 | task: ScheduleAppointmentTask,
89 | userVisit: UserVisitSuccessData,
90 | params: BaseParams,
91 | ): Promise {
92 | if (!this.settingAppointment) {
93 | this.settingAppointment = true;
94 | const scheduleAppointment = new Scheduler(params, userVisit);
95 | const res = await scheduleAppointment.handle(task);
96 | if (res.isDone) {
97 | this.stop();
98 | }
99 |
100 | if (res.status) {
101 | dispatchSearchStatus(res.status);
102 | }
103 |
104 | this.settingAppointment = false;
105 | } else {
106 | this.priorityQueue.enqueue(task);
107 | }
108 | }
109 |
110 | async handle(task: Task, config: WorkerConfig): Promise {
111 | const { locations, dateRangeForAppointment, userVisit, httpService } = config;
112 | const params: BaseParams = {
113 | priorityQueue: this.priorityQueue,
114 | slotsQueue: this.slotsQueue,
115 | storage: this.storageService,
116 | httpService,
117 | };
118 |
119 | return match(task)
120 | .with({ type: TaskType.GetServiceIdByLocationId }, (task) =>
121 | new GetServiceByLocationHandler(params, locations).handle(task),
122 | )
123 | .with({ type: TaskType.GetServiceCalendar }, (task) =>
124 | new GetServiceCalendarHandler(params, dateRangeForAppointment).handle(task),
125 | )
126 | .with({ type: TaskType.GetCalendarSlot }, (task) => new GetSlotForCalendarHandler(params).handle(task))
127 | .with({ type: TaskType.ScheduleAppointment }, (task) => this.scheduleAppointment(task, userVisit, params))
128 | .exhaustive();
129 | }
130 | }
131 |
--------------------------------------------------------------------------------
/src/components/MainButton/MainButton.test.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import browser from 'webextension-polyfill';
3 | import { render, screen, waitFor } from '@testing-library/react';
4 | import userEvent from '@testing-library/user-event';
5 |
6 | import { buttons as Content } from '@src/content.json';
7 | import { SearchStatus, SearchStatusType } from '@src/lib/internal-types';
8 | import { ActionTypes, PlatformMessage } from '@src/platform-message';
9 | import { MainButton } from './MainButton';
10 | import { SearchMessage } from './SearchMessage';
11 |
12 | describe('Render MainButton', () => {
13 | it('should render a "stop search" button if search is started or warning', () => {
14 | render();
15 |
16 | expect(screen.getByRole('button', { name: Content.stopSearch })).toBeInTheDocument();
17 | });
18 |
19 | it('Should render a "waiting" button if search is waiting', () => {
20 | render();
21 |
22 | expect(screen.getByRole('button', { name: Content.waiting })).toBeInTheDocument();
23 | });
24 |
25 | it('Should render a "search" button if search is stopped, warning or waiting', () => {
26 | render();
27 |
28 | expect(screen.getByRole('button', { name: Content.search })).toBeInTheDocument();
29 | });
30 |
31 | it('Should disable the "search" button if enabled is false', () => {
32 | render();
33 |
34 | expect(screen.getByRole('button', { name: Content.search })).toBeDisabled();
35 | });
36 | });
37 |
38 | describe('Render search message', () => {
39 | it('Should render an error message if searchStatus type is error', () => {
40 | const searchStatus: SearchStatus = { type: SearchStatusType.Error, message: 'Something went wrong' };
41 |
42 | render();
43 |
44 | const errorMessage = screen.getByText(/something went wrong/i);
45 | expect(errorMessage).toBeInTheDocument();
46 | expect(errorMessage).toHaveClass('error');
47 | });
48 |
49 | it('Should render a warning message if searchStatus type is warning', () => {
50 | const searchStatus: SearchStatus = { type: SearchStatusType.Warning, message: 'Be careful' };
51 |
52 | render();
53 |
54 | const warning = screen.getByText(/be careful/i);
55 | expect(warning).toBeInTheDocument();
56 | expect(warning).toHaveClass('warning');
57 | });
58 |
59 | it('Should render a message with no color class if searchStatus type is not error or warning', () => {
60 | const searchStatus: SearchStatus = { type: SearchStatusType.Stopped, message: 'No message' };
61 |
62 | render();
63 |
64 | const message = screen.getByText(/no message/i);
65 | expect(message).toBeInTheDocument();
66 |
67 | expect(message).not.toHaveClass('error');
68 | expect(message).not.toHaveClass('warning');
69 | });
70 | });
71 |
72 | describe('Integrate MainButton', () => {
73 | it('Should send the StartSearch message to the browser after clicking a stopped button', async () => {
74 | const onMessage = jest.fn();
75 | browser.runtime.onMessage.addListener(onMessage);
76 |
77 | render();
78 |
79 | const searchButton = screen.getByText(Content.search);
80 | userEvent.click(searchButton);
81 |
82 | await waitFor(() => expect(onMessage).toHaveBeenCalledWith({ action: ActionTypes.StartSearch }));
83 | });
84 |
85 | it('Should send the StopSearch message to the browser after clicking an active button', async () => {
86 | const onMessage = jest.fn();
87 |
88 | browser.runtime.onMessage.addListener(onMessage);
89 |
90 | render();
91 |
92 | const searchButton = screen.getByText(Content.stopSearch);
93 | userEvent.click(searchButton);
94 |
95 | await waitFor(() => expect(onMessage).toHaveBeenCalledWith({ action: ActionTypes.StopSearch }));
96 | });
97 |
98 | it('Should set the search status to error when error occurs', async () => {
99 | const onMessage = jest.fn((message: PlatformMessage) => {
100 | if (message.action === ActionTypes.StartSearch) {
101 | browser.runtime.sendMessage({
102 | action: ActionTypes.SetSearchStatus,
103 | status: { type: SearchStatusType.Error },
104 | } as PlatformMessage);
105 | }
106 | });
107 |
108 | browser.runtime.onMessage.addListener(onMessage);
109 |
110 | render();
111 |
112 | const searchButton = screen.getByText(Content.search);
113 | userEvent.click(searchButton);
114 |
115 | await waitFor(() =>
116 | expect(onMessage).toHaveBeenCalledWith({
117 | action: ActionTypes.SetSearchStatus,
118 | status: { type: SearchStatusType.Error },
119 | }),
120 | );
121 | });
122 | });
123 |
--------------------------------------------------------------------------------
/src/validators/validators.test.ts:
--------------------------------------------------------------------------------
1 | import {
2 | validateIsraeliIdNumber,
3 | validatePhoneNumber,
4 | validateNumberOfAllowedCities,
5 | validateStartDate,
6 | validateEndDate,
7 | } from './validators';
8 | import { startOfDay, addDays } from 'date-fns';
9 |
10 | describe('validateIsraeliIdNumber', () => {
11 | test('returns true for a valid Israeli ID number', () => {
12 | expect(validateIsraeliIdNumber('123456782')).toBe(true);
13 | });
14 |
15 | test('returns false for a null or undefined input', () => {
16 | expect(validateIsraeliIdNumber(null)).toBe(false);
17 | expect(validateIsraeliIdNumber(undefined)).toBe(false);
18 | });
19 |
20 | test('returns false for an input that is not a string or number', () => {
21 | expect(validateIsraeliIdNumber({ id: '123456783' })).toBe(false);
22 | });
23 |
24 | test('returns false for an input that is not a valid Israeli ID number', () => {
25 | expect(validateIsraeliIdNumber('123456789')).toBe(false);
26 | expect(validateIsraeliIdNumber('12345678')).toBe(false);
27 | expect(validateIsraeliIdNumber('12345678a')).toBe(false);
28 | });
29 | });
30 |
31 | describe('validateNumberOfAllowedCities', () => {
32 | it('should return false for empty array input', () => {
33 | expect(validateNumberOfAllowedCities([])).toBe(false);
34 | });
35 |
36 | it('should return true for array input with 4 or fewer elements', () => {
37 | expect(validateNumberOfAllowedCities(['city1', 'city2', 'city3', 'city4'])).toBe(true);
38 | });
39 |
40 | it('should return false for array input with more than 4 elements', () => {
41 | expect(validateNumberOfAllowedCities(['city1', 'city2', 'city3', 'city4', 'city5'])).toBe(false);
42 | });
43 | });
44 |
45 | describe('validatePhoneNumber', () => {
46 | it('should return true for valid phone numbers', () => {
47 | expect(validatePhoneNumber('0521234567')).toBe(true);
48 | expect(validatePhoneNumber('0505555555')).toBe(true);
49 | expect(validatePhoneNumber('0549876543')).toBe(true);
50 | });
51 |
52 | it('should return false for invalid phone numbers', () => {
53 | expect(validatePhoneNumber('0521234')).toBe(false);
54 | expect(validatePhoneNumber('050555555')).toBe(false);
55 | expect(validatePhoneNumber('05498765432')).toBe(false);
56 | expect(validatePhoneNumber('052a234567')).toBe(false);
57 | expect(validatePhoneNumber('052 123 4567')).toBe(false);
58 | expect(validatePhoneNumber('052-123-4567')).toBe(false);
59 | expect(validatePhoneNumber('05212345678')).toBe(false);
60 | expect(validatePhoneNumber('1234567890')).toBe(false);
61 | });
62 | });
63 |
64 | describe('validateStartDate', () => {
65 | it('should return false if startDate is 0 - not selected', () => {
66 | expect(validateStartDate(0)).toBe(false);
67 | });
68 |
69 | it('should return true if startDate is after or equal to today', () => {
70 | expect(validateStartDate(startOfDay(new Date()).getTime())).toBe(true);
71 | expect(validateStartDate(startOfDay(addDays(new Date(), 1)).getTime())).toBe(true);
72 | });
73 |
74 | it('should return false if startDate is before today', () => {
75 | const startDate = startOfDay(addDays(new Date(), -1));
76 | expect(validateStartDate(startDate.getTime())).toBe(false);
77 | });
78 |
79 | it('should return true if startDate is after today and before endDate', () => {
80 | const startDate = startOfDay(addDays(new Date(), 1));
81 | const endDate = startOfDay(addDays(new Date(), 7));
82 | expect(validateStartDate(startDate.getTime(), endDate.getTime())).toBe(true);
83 | });
84 |
85 | it('should return false if startDate is before today and before endDate', () => {
86 | const startDate = startOfDay(addDays(new Date(), -1));
87 | const endDate = startOfDay(addDays(new Date(), 7));
88 | expect(validateStartDate(startDate.getTime(), endDate.getTime())).toBe(false);
89 | });
90 |
91 | it('should return false if startDate is after endDate', () => {
92 | const startDate = startOfDay(addDays(new Date(), 7));
93 | const endDate = startOfDay(addDays(new Date(), 1));
94 | expect(validateStartDate(startDate.getTime(), endDate.getTime())).toBe(false);
95 | });
96 | });
97 |
98 | describe('validateEndDate', () => {
99 | it('returns false for endDate 0 - not selected', () => {
100 | expect(validateEndDate(0)).toBe(false);
101 | });
102 |
103 | it('returns true when endDate is after today', () => {
104 | const endDate = addDays(new Date(), 1).getTime();
105 | expect(validateEndDate(endDate)).toBe(true);
106 | });
107 |
108 | it('returns false when endDate is before today', () => {
109 | const endDate = addDays(new Date(), -1).getTime();
110 | expect(validateEndDate(endDate)).toBe(false);
111 | });
112 |
113 | it('returns true when endDate is after startDate', () => {
114 | const startDate = new Date().getTime();
115 | const endDate = addDays(new Date(), 1).getTime();
116 | expect(validateEndDate(endDate, startDate)).toBe(true);
117 | });
118 |
119 | it('returns false when endDate is before startDate', () => {
120 | const startDate = new Date().getTime();
121 | const endDate = addDays(new Date(), -1).getTime();
122 | expect(validateEndDate(endDate, startDate)).toBe(false);
123 | });
124 |
125 | it('returns true when endDate is equal to startDate', () => {
126 | const startDate = new Date().getTime();
127 | const endDate = new Date(startDate).getTime();
128 | expect(validateEndDate(endDate, startDate)).toBe(true);
129 | });
130 | });
131 |
--------------------------------------------------------------------------------
/src/lib/http.ts:
--------------------------------------------------------------------------------
1 | import axios, { AxiosError, AxiosInstance } from 'axios';
2 | import { ServiceIds } from './constants';
3 | import {
4 | AnswerQuestionRequest,
5 | AppointmentSetRequest,
6 | AppointmentSetResponse,
7 | LocationServicesRequest,
8 | LocationServicesResponse,
9 | PrepareVisitData,
10 | PrepareVisitResponse,
11 | SearchAvailableDatesRequest,
12 | SearchAvailableDatesResponse,
13 | SearchAvailableSlotsRequest,
14 | SearchAvailableSlotsResponse,
15 | } from './api';
16 | import { DateUtils } from './utils';
17 | import { EnrichedService, Service } from './internal-types';
18 | import { toService } from './mappers';
19 | import { GetUserInfoResponse } from '@src/lib/api/user-info';
20 |
21 | export const BaseURL = 'https://piba-api.myvisit.com/CentralAPI';
22 | export const PartialURLs = {
23 | createAnonymousSession: 'UserCreateAnonymous',
24 | locationSearch: 'LocationSearch',
25 | locationServices: 'LocationGetServices',
26 | searchAvailableDates: 'SearchAvailableDates',
27 | searchAvailableSlots: 'SearchAvailableSlots',
28 | setAppointment: 'AppointmentSet',
29 | cancelAppointment: 'AppointmentCancel',
30 | prepareVisit: 'Organization/56/PrepareVisit',
31 | getUserInfo: 'UserGetInfo',
32 | answer: (visitToken: string): string => `PreparedVisit/${visitToken}/Answer`,
33 | };
34 |
35 | export const Urls = {
36 | createAnonymousSession: `${BaseURL}/${PartialURLs.createAnonymousSession}`,
37 | locationSearch: `${BaseURL}/${PartialURLs.locationSearch}`,
38 | locationServices: `${BaseURL}/${PartialURLs.locationServices}`,
39 | searchAvailableDates: `${BaseURL}/${PartialURLs.searchAvailableDates}`,
40 | searchAvailableSlots: `${BaseURL}/${PartialURLs.searchAvailableSlots}`,
41 | setAppointment: `${BaseURL}/${PartialURLs.setAppointment}`,
42 | cancelAppointment: `${BaseURL}/${PartialURLs.cancelAppointment}`,
43 | prepareVisit: `${BaseURL}/${PartialURLs.prepareVisit}`,
44 | getUserInfo: `${BaseURL}/${PartialURLs.getUserInfo}`,
45 | answer: (visitToken: string): string => `${BaseURL}/${PartialURLs.answer(visitToken)}`,
46 | };
47 |
48 | export class HttpService {
49 | private readonly httpClient: AxiosInstance;
50 |
51 | constructor(errorHandler: (err: AxiosError) => Promise) {
52 | this.httpClient = axios.create({
53 | headers: {
54 | // MyVisit default configuration
55 | 'application-api-key': 'D7662A08-48D1-4BC8-9E45-7F9DDF8987E3',
56 | 'application-name': 'PibaV1',
57 | 'accept-language': 'en',
58 | },
59 | withCredentials: true,
60 | });
61 |
62 | this.addRejectInterceptor(errorHandler);
63 | }
64 |
65 | public updateVisitToken = (visitToken: string): void => {
66 | this.httpClient.defaults.headers['Preparedvisittoken'] = visitToken;
67 | };
68 |
69 | public addRejectInterceptor = (errorHandler: (err: AxiosError) => Promise): void => {
70 | this.httpClient.interceptors.response.use(
71 | (res) => res,
72 | async (error: AxiosError) => {
73 | await errorHandler(error);
74 | return Promise.reject(error);
75 | },
76 | );
77 | };
78 |
79 | public async getServiceIdByLocationId(
80 | locationId: number,
81 | serviceTypeId: number = ServiceIds.BiometricPassportAppointment,
82 | ): Promise {
83 | const params: LocationServicesRequest = {
84 | locationId,
85 | serviceTypeId,
86 | };
87 | return this.httpClient
88 | .get(Urls.locationServices, { params })
89 | .then((res) => (res.data.Results ?? []).map(toService));
90 | }
91 |
92 | public async getCalendars(serviceId: number, startDate: number): Promise {
93 | const params: SearchAvailableDatesRequest = {
94 | maxResults: 31,
95 | startDate: DateUtils.toApiFormattedDate(startDate),
96 | serviceId,
97 | };
98 | const result = await this.httpClient.get(Urls.searchAvailableDates, { params });
99 | return (result.data.Results ?? []).map((result) => ({
100 | ...result,
101 | serviceId,
102 | }));
103 | }
104 |
105 | public getAvailableSlotByCalendar(calendarId: number, serviceId: number): Promise {
106 | const params: SearchAvailableSlotsRequest = {
107 | CalendarId: calendarId,
108 | ServiceId: serviceId,
109 | dayPart: 0,
110 | };
111 | return this.httpClient
112 | .get(Urls.searchAvailableSlots, { params })
113 | .then((res) => (res?.data?.Results ?? []).map(({ Time }) => Time));
114 | }
115 |
116 | public prepareVisit(): Promise {
117 | return this.httpClient.post(Urls.prepareVisit).then((res) => res.data.Data);
118 | }
119 |
120 | public answer(answerRequest: AnswerQuestionRequest): Promise {
121 | return this.httpClient
122 | .post(Urls.answer(answerRequest.PreparedVisitToken), answerRequest)
123 | .then((res) => res.data.Data);
124 | }
125 |
126 | public setAppointment(visitToken: string, params: AppointmentSetRequest): Promise {
127 | return this.httpClient
128 | .get(Urls.setAppointment, {
129 | params,
130 | headers: {
131 | PreparedVisitToken: visitToken,
132 | },
133 | })
134 | .then((res) => res.data);
135 | }
136 |
137 | public getUserInfo(): Promise {
138 | return this.httpClient.get(Urls.getUserInfo).then((res) => res.data);
139 | }
140 | }
141 |
--------------------------------------------------------------------------------
/jest.config.js:
--------------------------------------------------------------------------------
1 | // For a detailed explanation regarding each configuration property, visit:
2 | // https://jestjs.io/docs/en/configuration.html
3 |
4 | module.exports = {
5 | // All imported modules in your tests should be mocked automatically
6 | // automock: false,
7 | // Stop running tests after `n` failures
8 | // bail: 0,
9 | // Respect "browser" field in package.json when resolving modules
10 | // browser: false,
11 | // The directory where Jest should store its cached dependency information
12 | // cacheDirectory: "/tmp/jest_rs",
13 | // Automatically clear mock calls and instances between every test
14 | // clearMocks: true,
15 | // Indicates whether the coverage information should be collected while executing the test
16 | // collectCoverage: false,
17 | // An array of glob patterns indicating a set of files for which coverage information should be collected
18 | // collectCoverageFrom: null,
19 | // The directory where Jest should output its coverage files
20 | // coverageDirectory: "coverage",
21 | // An array of regexp pattern strings used to skip coverage collection
22 | // coveragePathIgnorePatterns: [
23 | // "/node_modules/"
24 | // ],
25 | // A list of reporter names that Jest uses when writing coverage reports
26 | // coverageReporters: [
27 | // "json",
28 | // "text",
29 | // "lcov",
30 | // "clover"
31 | // ],
32 | // An object that configures minimum threshold enforcement for coverage results
33 | // coverageThreshold: null,
34 | // A path to a custom dependency extractor
35 | // dependencyExtractor: null,
36 | // Make calling deprecated APIs throw helpful error messages
37 | // errorOnDeprecated: false,
38 | // Force coverage collection from ignored files using an array of glob patterns
39 | // forceCoverageMatch: [],
40 | // A path to a module which exports an async function that is triggered once before all test suites
41 | // globalSetup: null,
42 | // A path to a module which exports an async function that is triggered once after all test suites
43 | // globalTeardown: null,
44 | // A set of global variables that need to be available in all test environments
45 | // globals: {},
46 | // The maximum amount of workers used to run your tests. Can be specified as % or a number. E.g. maxWorkers: 10% will use 10% of your CPU amount + 1 as the maximum worker number. maxWorkers: 2 will use a maximum of 2 workers.
47 | // maxWorkers: "50%",
48 | // An array of directory names to be searched recursively up from the requiring module's location
49 | // moduleDirectories: [
50 | // "node_modules"
51 | // ],
52 | // An array of file extensions your modules use
53 | moduleFileExtensions: ['js', 'json', 'jsx', 'ts', 'tsx', 'node'],
54 | // A map from regular expressions to module names that allow to stub out resources with a single module
55 | moduleNameMapper: {
56 | '@src/(.*)': '/src/$1',
57 | '\\.(css|less|scss|sss|styl)$': '/node_modules/jest-css-modules',
58 | '@test/(.*)': '/test/$1',
59 | },
60 | // An array of regexp pattern strings, matched against all module paths before considered 'visible' to the module loader
61 | // modulePathIgnorePatterns: [],
62 | // Activates notifications for test results
63 | // notify: false,
64 | // An enum that specifies notification mode. Requires { notify: true }
65 | // notifyMode: "failure-change",
66 | // A preset that is used as a base for Jest's configuration
67 | // preset: null,
68 | // Run tests from one or more projects
69 | // projects: null,
70 | // Use this configuration option to add custom reporters to Jest
71 | // reporters: undefined,
72 | // Automatically reset mock state between every test
73 | // resetMocks: false,
74 | // Reset the module registry before running each individual test
75 | // resetModules: false,
76 | // A path to a custom resolver
77 | // resolver: null,
78 | // Automatically restore mock state between every test
79 | // restoreMocks: false,
80 | // The root directory that Jest should scan for tests and modules within
81 | // rootDir: null,
82 | // A list of paths to directories that Jest should use to search for files in
83 | roots: [''],
84 | // Allows you to use a custom runner instead of Jest's default test runner
85 | // runner: "jest-runner",
86 | // The paths to modules that run some code to configure or set up the testing environment before each test
87 | setupFiles: [],
88 | // A list of paths to modules that run some code to configure or set up the testing framework before each test
89 | setupFilesAfterEnv: ['./test/setupTests.ts'],
90 | // A list of paths to snapshot serializer modules Jest should use for snapshot testing
91 | // snapshotSerializers: [],
92 | // The test environment that will be used for testing
93 | testEnvironment: 'jest-environment-jsdom',
94 | // Options that will be passed to the testEnvironment
95 | testEnvironmentOptions: {
96 | url: 'https://piba-api.myvisit.com',
97 | },
98 | // Adds a location field to test results
99 | // testLocationInResults: false,
100 | // The glob patterns Jest uses to detect test files
101 | // testMatch: [
102 | // "**/__tests__/**/*.[jt]s?(x)",
103 | // "**/?(*.)+(spec|test).[tj]s?(x)"
104 | // ],
105 | // An array of regexp pattern strings that are matched against all test paths, matched tests are skipped
106 | testPathIgnorePatterns: ['/node_modules/', 'stories.tsx'],
107 | // The regexp pattern or array of patterns that Jest uses to detect test files
108 | // testRegex: [],
109 | // This option allows the use of a custom results processor
110 | // testResultsProcessor: null,
111 | // This option allows use of a custom test runner
112 | // testRunner: "jasmine2",
113 | // This option sets the URL for the jsdom environment. It is reflected in properties such as location.href
114 | // testURL: 'https://central.myvisit.com',
115 | // Setting this value to "fake" allows the use of fake timers for functions such as "setTimeout"
116 | // timers: "real",
117 | // A map from regular expressions to paths to transformers
118 | // transform: null,
119 | transform: {
120 | '\\.tsx?$': 'ts-jest',
121 | '^.+\\.svg$': '/test/svg-transformer.js',
122 | },
123 | // An array of regexp pattern strings that are matched against all source file paths, matched files will skip transformation
124 | // transformIgnorePatterns: ["/node_modules/"],
125 | // An array of regexp pattern strings that are matched against all modules before the module loader will automatically return a mock for them
126 | // unmockedModulePathPatterns: undefined,
127 | // Indicates whether each individual test should be reported during the run
128 | // verbose: null,
129 | // An array of regexp patterns that are matched against all source file paths before re-running tests in watch mode
130 | // watchPathIgnorePatterns: [],
131 | // Whether to use watchman for file crawling
132 | // watchman: true,
133 | };
134 |
--------------------------------------------------------------------------------
/src/lib/locations.ts:
--------------------------------------------------------------------------------
1 | import { Location } from './internal-types';
2 |
3 | export const Locations: Location[] = [
4 | {
5 | id: 799,
6 | city: 'גבעתיים',
7 | address: 'סירקין 17 ',
8 | description: 'לשכת רמת גן גבעתיים',
9 | name: 'לשכת רמת גן-גבעתיים',
10 | },
11 | {
12 | id: 802,
13 | city: 'תל אביב',
14 | address: 'מנחם בגין 125 ',
15 | description: 'לשכת תל אביב מרכז (קריית הממשלה)',
16 | name: 'לשכת תל אביב מרכז (קריית הממשלה)',
17 | },
18 | {
19 | id: 807,
20 | city: 'כפר סבא',
21 | address: 'ויצמן 140 ',
22 | description: 'לשכת כפר סבא',
23 | name: 'לשכת כפר סבא',
24 | },
25 | {
26 | id: 809,
27 | city: 'פתח תקוה',
28 | address: 'מוטה גור 4 מגדלי עופר',
29 | description: 'לשכת פתח תקוה',
30 | name: 'לשכת פתח תקוה',
31 | },
32 | {
33 | id: 816,
34 | city: 'חדרה',
35 | address: 'דוד שמעוני 42 ',
36 | description: 'לשכת חדרה',
37 | name: 'לשכת חדרה',
38 | },
39 | {
40 | id: 817,
41 | city: 'נתניה',
42 | address: 'דוד רמז 13 ',
43 | description: 'לשכת נתניה',
44 | name: 'לשכת נתניה',
45 | },
46 | {
47 | id: 818,
48 | city: 'רמלה',
49 | address: 'הרצל 91 ',
50 | description: 'לשכת רמלה',
51 | name: 'לשכת רמלה',
52 | },
53 | {
54 | id: 819,
55 | city: 'רחובות',
56 | address: 'מוטי קינד 10 ',
57 | description: 'לשכת רחובות',
58 | name: 'לשכת רחובות',
59 | },
60 | {
61 | id: 821,
62 | city: 'חולון',
63 | address: 'שדרות ירושלים 164 ',
64 | description: 'לשכת חולון',
65 | name: 'לשכת חולון',
66 | },
67 | {
68 | id: 822,
69 | city: 'ירושלים',
70 | address: 'שלומציון המלכה 1 ',
71 | description: 'לשכת ירושלים',
72 | name: 'לשכת ירושלים',
73 | },
74 | {
75 | id: 823,
76 | city: 'מודיעין',
77 | address: 'תלתן 1 ',
78 | description: 'לשכת מודיעין-מכבים-רעות (לתושבי העיר בלבד)',
79 | name: 'לשכת מודיעין-מכבים-רעות (לתושבי העיר בלבד)',
80 | },
81 | {
82 | id: 824,
83 | city: 'מבשרת ציון',
84 | address: 'חניון בית ספר הראל ',
85 | description: 'לשכת מבשרת ציון',
86 | name: 'לשכת מבשרת ציון',
87 | },
88 | {
89 | id: 825,
90 | city: 'ירושלים',
91 | address: 'אליהו קורן 25 ',
92 | description: 'לשכת ירושלים דרום- הר חומה',
93 | name: 'לשכת ירושלים דרום-הר חומה',
94 | },
95 | {
96 | id: 826,
97 | city: 'בני ברק',
98 | address: "דרך זאב ז'בוטינסקי 168 מגדלי שק",
99 | description: 'לשכת בני ברק',
100 | name: 'לשכת בני ברק',
101 | },
102 | {
103 | id: 827,
104 | city: 'תל אביב',
105 | address: 'סלמה 53 קומה 4',
106 | description: 'לשכת תל אביב דרום (יפו)',
107 | name: 'לשכת תל אביב דרום (יפו)',
108 | },
109 | {
110 | id: 828,
111 | city: 'ראש העין',
112 | address: 'שלום שבזי 29 ',
113 | description: 'לשכת ראש העין',
114 | name: 'לשכת ראש העין',
115 | },
116 | {
117 | id: 841,
118 | city: 'נתיבות',
119 | address: 'שדרות ירושלים 10 ',
120 | description: 'לשכת נתיבות',
121 | name: 'לשכת נתיבות',
122 | },
123 | {
124 | id: 842,
125 | city: 'באר שבע',
126 | address: 'התקווה 4 קריית הממשלה',
127 | description: 'לשכת באר שבע',
128 | name: 'לשכת באר שבע',
129 | },
130 | {
131 | id: 843,
132 | city: 'דימונה',
133 | address: 'בניין עיריית דימונה ',
134 | description: 'לשכת דימונה',
135 | name: 'לשכת דימונה',
136 | },
137 | {
138 | id: 849,
139 | city: 'רהט',
140 | address: 'עיריית רהט בניין העירייה',
141 | description: 'לשכת רהט',
142 | name: 'לשכת רהט',
143 | },
144 | {
145 | id: 852,
146 | city: 'עכו',
147 | address: 'שלום הגליל 1 ',
148 | description: 'לשכת עכו',
149 | name: 'לשכת עכו',
150 | },
151 | {
152 | id: 853,
153 | city: 'נהריה',
154 | address: 'אירית 2 קניון נהריה',
155 | description: 'לשכת נהריה',
156 | name: 'לשכת נהריה ',
157 | },
158 | {
159 | id: 854,
160 | city: 'אשדוד',
161 | address: 'דרך מנחם בגין 1 מרכז צימר',
162 | description: 'לשכת אשדוד',
163 | name: 'לשכת אשדוד',
164 | },
165 | {
166 | id: 855,
167 | city: 'אשקלון',
168 | address: 'ברל כצנלסון 9 ',
169 | description: 'לשכת אשקלון',
170 | name: 'לשכת אשקלון',
171 | },
172 | {
173 | id: 856,
174 | city: 'חיפה',
175 | address: 'שדרות פל ים 15 א ',
176 | description: 'לשכת חיפה',
177 | name: 'לשכת חיפה',
178 | },
179 | {
180 | id: 858,
181 | city: 'עפולה',
182 | address: 'יהושע חנקין 1 ',
183 | description: 'לשכת עפולה',
184 | name: 'לשכת עפולה',
185 | },
186 | {
187 | id: 859,
188 | city: 'בית שמש',
189 | address: 'אבא נעמת 1 ',
190 | description: 'לשכת בית שמש',
191 | name: 'לשכת בית שמש',
192 | },
193 | {
194 | id: 860,
195 | city: 'טבריה',
196 | address: 'יהודה הלוי 1 מרכז ביג פאשן דנילוף',
197 | description: 'לשכת טבריה',
198 | name: 'לשכת טבריה',
199 | },
200 | {
201 | id: 861,
202 | city: 'נוף הגליל',
203 | address: 'מעלה יצחק 29 ',
204 | description: ' לשכת נוף הגליל (נצרת עילית)',
205 | name: ' לשכת נוף הגליל (נצרת עילית)',
206 | },
207 | {
208 | id: 866,
209 | city: 'צפת',
210 | address: 'וייצמן 4 ',
211 | description: 'לשכת צפת',
212 | name: 'לשכת צפת',
213 | },
214 | {
215 | id: 867,
216 | city: 'קרית שמונה',
217 | address: ' 37 הרצל ',
218 | description: 'לשכת קרית שמונה',
219 | name: 'לשכת קרית שמונה',
220 | },
221 | {
222 | id: 868,
223 | city: 'כרמיאל',
224 | address: 'החרושת 9 ',
225 | description: 'לשכת כרמיאל',
226 | name: 'לשכת כרמיאל',
227 | },
228 | {
229 | id: 869,
230 | city: 'ראשון לציון',
231 | address: 'ישראל גלילי 3 ',
232 | description: 'לשכת ראשון לציון',
233 | name: 'לשכת ראשון לציון',
234 | },
235 | {
236 | id: 871,
237 | city: 'הרצליה',
238 | address: 'הדר 2 ',
239 | description: 'לשכת הרצליה',
240 | name: 'לשכת הרצליה',
241 | },
242 | {
243 | id: 872,
244 | city: 'אילת',
245 | address: 'שדרות התמרים 2 ',
246 | description: 'לשכת אילת',
247 | name: 'לשכת אילת',
248 | },
249 | {
250 | id: 889,
251 | city: 'סחנין',
252 | address: "עיריית סח'נין ",
253 | description: "לשכת סח'נין",
254 | name: "לשכת סח'נין",
255 | },
256 | {
257 | id: 893,
258 | city: 'מעלה אדומים',
259 | address: 'דרך קדם 5 ',
260 | description: 'לשכת מעלה אדומים',
261 | name: 'לשכת מעלה אדומים ',
262 | },
263 | {
264 | id: 899,
265 | city: 'מודיעין עילית',
266 | address: 'חזון דוד ',
267 | description: 'לשכת מודיעין עילית',
268 | name: 'לשכת מודיעין עילית',
269 | },
270 | {
271 | id: 951,
272 | city: 'מעלות תרשיחא',
273 | address: 'בן גוריון 1 ',
274 | description: 'לשכת מעלות תרשיחא',
275 | name: 'לשכת מעלות תרשיחא',
276 | },
277 | {
278 | id: 978,
279 | city: 'קרית אתא',
280 | address: 'דרך חיפה 52 ',
281 | description: 'לשכת קריות (קניון שער הצפון)',
282 | name: 'לשכת קריות (קניון שער הצפון)',
283 | },
284 | {
285 | id: 979,
286 | city: 'טייבה',
287 | address: 'אלבלדיה ',
288 | description: 'לשכת טייבה',
289 | name: 'לשכת טייבה',
290 | },
291 | {
292 | id: 1019,
293 | city: 'ירושלים',
294 | address: ' מעבר קלנדיה ',
295 | description: 'לשכת קלנדיה قلنديا',
296 | name: 'לשכת קלנדיה قلنديا',
297 | },
298 | {
299 | id: 1037,
300 | city: 'יקנעם',
301 | address: 'התמר 2 ',
302 | description: 'לשכת יקנעם',
303 | name: 'לשכת יקנעם',
304 | },
305 | {
306 | id: 1038,
307 | city: 'אריאל',
308 | address: 'יהודה 5 ',
309 | description: 'לשכת אריאל',
310 | name: 'לשכת אריאל',
311 | },
312 | {
313 | id: 1071,
314 | city: 'שדרות',
315 | address: 'מנחם בגין 4 ',
316 | description: 'לשכת שדרות',
317 | name: 'לשכת שדרות',
318 | },
319 | {
320 | id: 1073,
321 | city: 'קרית גת',
322 | address: 'ככר פז 3 ',
323 | description: 'לשכת קרית גת',
324 | name: 'לשכת קרית גת',
325 | },
326 | {
327 | id: 1327,
328 | city: 'קצרין',
329 | address: 'מרכז מסחרי איתן ',
330 | description: 'לשכת קצרין',
331 | name: 'לשכת קצרין',
332 | },
333 | {
334 | id: 1599,
335 | city: 'ירושלים',
336 | address: ' 257 אלמדינה אל מנורה צור באהר',
337 | description: 'לשכת צור באהר صور باهر',
338 | name: 'לשכת צור באהר صور باهر',
339 | },
340 | {
341 | id: 1822,
342 | city: 'דיר אל אסד',
343 | address: "אל ג'בל מועצה מקומית דיר אל אסד",
344 | description: 'לשכת דיר אל אסד',
345 | name: 'לשכת דיר אל אסד',
346 | },
347 | {
348 | id: 2265,
349 | city: 'אום אל פחם',
350 | address: 'אל מדינה ',
351 | description: 'לשכת אום אל פחם',
352 | name: 'לשכת אום אל פחם',
353 | },
354 | ];
355 |
--------------------------------------------------------------------------------