├── .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 |