├── .DS_Store ├── .env.example ├── .eslintignore ├── .eslintrc.js ├── .eslintrc.json ├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md └── workflows │ └── linter.yml ├── .gitignore ├── .vscode └── settings.json ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── next.config.js ├── package-lock.json ├── package.json ├── pages ├── _app.tsx ├── api │ └── poll │ │ ├── [id].ts │ │ ├── [id] │ │ └── [secret].ts │ │ └── create.ts ├── how-to-vote.tsx ├── how-to.tsx ├── index.tsx ├── poll │ ├── [id].tsx │ └── [id] │ │ └── [secret].tsx ├── privacy.tsx └── recent-polls.tsx ├── public ├── .DS_Store ├── banner.png └── favicon.svg ├── src ├── components │ ├── BottomNav.tsx │ ├── Layout.tsx │ ├── LogoSVG.tsx │ ├── Navbar.tsx │ ├── SamayRBC.tsx │ ├── copyText │ │ ├── CopyTextMain.tsx │ │ └── index.tsx │ └── poll │ │ ├── AdminPollInfo.tsx │ │ ├── DeletePoll.tsx │ │ ├── MarkFinalTime.tsx │ │ ├── MarkTimes.tsx │ │ ├── MarkTimesOneOnOne.tsx │ │ ├── PollDateTime.tsx │ │ ├── PollTableAdmin.tsx │ │ ├── PollTableVoter.tsx │ │ ├── SubmitFinalTime.tsx │ │ ├── SubmitTimes.tsx │ │ └── VoterPollInfo.tsx ├── helpers │ ├── index.ts │ └── toastOptions.ts ├── models │ └── poll.ts ├── styles │ ├── bottom-nav.scss │ ├── datetime.scss │ ├── form.scss │ ├── global.scss │ ├── how-to.scss │ ├── navbar.scss │ ├── new-poll.scss │ ├── poll.scss │ ├── privacy.scss │ ├── rat.scss │ ├── samay-rbc.scss │ ├── voter-page.scss │ └── your-polls.scss └── utils │ ├── api │ └── server.ts │ ├── db.ts │ └── react-available-times.d.ts └── tsconfig.json /.DS_Store: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/anandbaburajan/samay/ae0c7716169723c5629f9a2c5cc007291126179a/.DS_Store -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | # In production, NEXT_MONGODB_URI is the connection string 2 | # In development, NEXT_MONGODB_URI is mongodb://localhost:27017/samayPolls 3 | NEXT_MONGODB_URI= 4 | 5 | # In production, NEXT_PUBLIC_BASE_URL is the base URL of your deployment; like https://samay.app 6 | # In development, NEXT_PUBLIC_BASE_URL is http://localhost:3000 7 | NEXT_PUBLIC_BASE_URL= 8 | 9 | # Key and IV for data encryption and decryption 10 | # Generate NEXT_PUBLIC_ENCRYPTION_KEY using $openssl rand -hex 16 11 | # Generate NEXT_PUBLIC_ENCRYPTION_IV using $openssl rand -hex 8 12 | NEXT_PUBLIC_ENCRYPTION_KEY= 13 | NEXT_PUBLIC_ENCRYPTION_IV= -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | 3 | build/ 4 | 5 | .eslintrc.js -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | env: { 3 | browser: true, 4 | node: true, 5 | es2020: true, 6 | }, 7 | parser: "@typescript-eslint/parser", 8 | parserOptions: { 9 | ecmaVersion: 2020, 10 | sourceType: "module", 11 | ecmaFeatures: { 12 | jsx: true, 13 | }, 14 | project: './tsconfig.json', 15 | }, 16 | plugins: ["@typescript-eslint", "react", "prettier"], 17 | extends: [ 18 | "airbnb-typescript", 19 | "airbnb/hooks", 20 | "plugin:@typescript-eslint/recommended", 21 | "plugin:react/recommended", 22 | "plugin:import/errors", 23 | "plugin:import/warnings", 24 | "plugin:import/typescript", 25 | "prettier", 26 | "prettier/@typescript-eslint", 27 | "prettier/react", 28 | ], 29 | rules: { 30 | "react/jsx-filename-extension": [1, { extensions: [".ts", ".tsx"] }], 31 | "react/react-in-jsx-scope": "off", 32 | "import/extensions": "off", 33 | "react/prop-types": "off", 34 | "jsx-a11y/anchor-is-valid": "off", 35 | "react/jsx-props-no-spreading": ["error", { custom: "ignore" }], 36 | "prettier/prettier": "warn", 37 | "react/no-unescaped-entities": "off", 38 | "import/no-cycle": [0, { ignoreExternal: true }], 39 | "prefer-const": "off", 40 | // needed because of https://github.com/typescript-eslint/typescript-eslint/blob/master/packages/eslint-plugin/docs/rules/no-use-before-define.md#how-to-use & https://stackoverflow.com/questions/63818415/react-was-used-before-it-was-defined 41 | "no-use-before-define": "off", 42 | "@typescript-eslint/no-use-before-define": [ 43 | "error", 44 | { functions: false, classes: false, variables: true }, 45 | ], 46 | "no-underscore-dangle": ["error", { "allow": ["_id", "__persistor"] }] 47 | 48 | }, 49 | globals: { 50 | "React": "writable" 51 | }, 52 | settings: { 53 | "import/resolver": { 54 | "babel-module": { 55 | extensions: [".js", ".jsx", ".ts", ".tsx"], 56 | }, 57 | node: { 58 | extensions: [".js", ".jsx", ".ts", ".tsx"], 59 | paths: ["src"], 60 | }, 61 | }, 62 | }, 63 | }; 64 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "airbnb-typescript/base", 4 | "plugin:@typescript-eslint/eslint-recommended", 5 | "plugin:@typescript-eslint/recommended", 6 | "next" 7 | ], 8 | "parser": "@typescript-eslint/parser", 9 | "plugins": [ 10 | "@typescript-eslint" 11 | ], 12 | "parserOptions": { 13 | "project": "./tsconfig.json" 14 | }, 15 | "rules": { 16 | "object-curly-newline": "off", 17 | "arrow-body-style": "off", 18 | "no-underscore-dangle": "off", 19 | "prefer-destructuring": "off", 20 | "@typescript-eslint/indent": "off", 21 | "@typescript-eslint/semi": "warn", 22 | "@typescript-eslint/no-non-null-assertion": "off" 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **Desktop (please complete the following information):** 27 | - OS: [e.g. iOS] 28 | - Browser [e.g. chrome, safari] 29 | - Version [e.g. 22] 30 | 31 | **Smartphone (please complete the following information):** 32 | - Device: [e.g. iPhone6] 33 | - OS: [e.g. iOS8.1] 34 | - Browser [e.g. stock browser, safari] 35 | - Version [e.g. 22] 36 | 37 | **Additional context** 38 | Add any other context about the problem here. 39 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /.github/workflows/linter.yml: -------------------------------------------------------------------------------- 1 | name: Lint 2 | on: 3 | pull_request: 4 | branches: [main] 5 | jobs: 6 | eslint: 7 | name: eslint 8 | runs-on: ubuntu-latest 9 | steps: 10 | - uses: actions/checkout@v1 11 | - name: install node v12 12 | uses: actions/setup-node@v1 13 | with: 14 | node-version: 12 15 | - name: yarn install 16 | run: yarn install 17 | - name: eslint 18 | uses: icrawl/action-eslint@v1 19 | with: 20 | custom-glob: "**/*.{ts,tsx}" 21 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | lerna-debug.log* 8 | 9 | # Diagnostic reports (https://nodejs.org/api/report.html) 10 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 11 | 12 | # Runtime data 13 | pids 14 | *.pid 15 | *.seed 16 | *.pid.lock 17 | 18 | # Directory for instrumented libs generated by jscoverage/JSCover 19 | lib-cov 20 | 21 | # Coverage directory used by tools like istanbul 22 | coverage 23 | *.lcov 24 | 25 | # nyc test coverage 26 | .nyc_output 27 | 28 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 29 | .grunt 30 | 31 | # Bower dependency directory (https://bower.io/) 32 | bower_components 33 | 34 | # node-waf configuration 35 | .lock-wscript 36 | 37 | # Compiled binary addons (https://nodejs.org/api/addons.html) 38 | build/Release 39 | 40 | # Dependency directories 41 | node_modules/ 42 | 43 | # TypeScript v1 declaration files 44 | typings/ 45 | 46 | # TypeScript cache 47 | *.tsbuildinfo 48 | 49 | # Optional npm cache directory 50 | .npm 51 | 52 | # Optional eslint cache 53 | .eslintcache 54 | 55 | # Next.js build output and env files 56 | .next 57 | next-env.d.ts 58 | 59 | .env 60 | 61 | .DS_Store 62 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.codeActionsOnSave": { 3 | "source.fixAll.eslint": "explicit" 4 | }, 5 | "editor.formatOnSave": true 6 | } 7 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to making participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, sex characteristics, gender identity and expression, 9 | level of experience, education, socio-economic status, nationality, personal 10 | appearance, race, religion, or sexual identity and orientation. 11 | 12 | ## Our Standards 13 | 14 | Examples of behavior that contributes to creating a positive environment 15 | include: 16 | 17 | - Using welcoming and inclusive language 18 | - Being respectful of differing viewpoints and experiences 19 | - Gracefully accepting constructive criticism 20 | - Focusing on what is best for the community 21 | - Showing empathy towards other community members 22 | 23 | Examples of unacceptable behavior by participants include: 24 | 25 | - The use of sexualized language or imagery and unwelcome sexual attention or 26 | advances 27 | - Trolling, insulting/derogatory comments, and personal or political attacks 28 | - Public or private harassment 29 | - Publishing others' private information, such as a physical or electronic 30 | address, without explicit permission 31 | - Other conduct which could reasonably be considered inappropriate in a 32 | professional setting 33 | 34 | ## Our Responsibilities 35 | 36 | Project maintainers are responsible for clarifying the standards of acceptable 37 | behavior and are expected to take appropriate and fair corrective action in 38 | response to any instances of unacceptable behavior. 39 | 40 | Project maintainers have the right and responsibility to remove, edit, or 41 | reject comments, commits, code, wiki edits, issues, and other contributions 42 | that are not aligned to this Code of Conduct, or to ban temporarily or 43 | permanently any contributor for other behaviors that they deem inappropriate, 44 | threatening, offensive, or harmful. 45 | 46 | ## Scope 47 | 48 | This Code of Conduct applies both within project spaces and in public spaces 49 | when an individual is representing the project or its community. Examples of 50 | representing a project or community include using an official project e-mail 51 | address, posting via an official social media account, or acting as an appointed 52 | representative at an online or offline event. Representation of a project may be 53 | further defined and clarified by project maintainers. 54 | 55 | ## Enforcement 56 | 57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 58 | reported by contacting [Anand Baburajan](anandbaburajan@gmail.com). All 59 | complaints will be reviewed and investigated and will result in a response that 60 | is deemed necessary and appropriate to the circumstances. The maintainers are 61 | obligated to maintain confidentiality with regard to the reporter of an incident. 62 | Further details of specific enforcement policies may be posted separately. 63 | 64 | Project maintainers who do not follow or enforce the Code of Conduct in good 65 | faith may face temporary or permanent repercussions as determined by other 66 | members of the project's leadership. 67 | 68 | ## Attribution 69 | 70 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 71 | available [here](https://www.contributor-covenant.org/version/1/4/code-of-conduct.html) 72 | 73 | [homepage]: https://www.contributor-covenant.org 74 | 75 | For answers to common questions about this code of conduct, see 76 | https://www.contributor-covenant.org/faq 77 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | ## Contributing 2 | 3 | Hi there! We're thrilled that you'd like to contribute to Samay! 4 | 5 | ## Issues and PRs 6 | 7 | If you have suggestions for how this project could be improved, or want to report a bug, open an issue! We'd love all and any contributions. If you have questions, too, we'd love to hear them. 8 | 9 | We'd also love PRs. If you're thinking of a large PR, please open up an issue first to discuss your proposed changes with a project maintainer! 10 | 11 | ## Submitting a pull request 12 | 13 | 1. Fork and clone the repository. 14 | 2. Configure and install the dependencies. 15 | 3. Make sure the tests and linters pass on your machine. 16 | 4. Create a new branch. 17 | 5. Make your change, add tests, and make sure the tests still pass. 18 | 6. Push to your fork and submit a pull request. 19 | 7. Pat your self on the back and wait for your pull request to be reviewed and merged. 20 | 21 | Here are a few things you can do that will increase the likelihood of your pull request being accepted: 22 | 23 | - Write and update tests. 24 | - Keep your changes as focused as possible. If there are multiple changes you would like to make that are not dependent upon each other, consider submitting them as separate pull requests. 25 | - Write a [good commit message](http://tbaggery.com/2008/04/19/a-note-about-git-commit-messages.html). 26 | 27 | Work in Progress pull requests are also welcome to get feedback early on, or if there is something blocking you. 28 | 29 | ## Resources 30 | 31 | - [How to Contribute to Open Source](https://opensource.guide/how-to-contribute/) 32 | - [Using Pull Requests](https://help.github.com/articles/about-pull-requests/) 33 | - [GitHub Help](https://help.github.com) 34 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Samay 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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 | 3 | Samay Logo 8 | 9 |

10 | 11 | Samay — free and open source group scheduling tool 12 | 13 |

14 |

15 | 16 | [![License](https://img.shields.io/github/license/anandbaburajan/samay?color=%23000000&style=for-the-badge)](https://github.com/anandbaburajan/samay/blob/main/LICENSE) 17 | [![Polls created: 2500+](https://shields.io/badge/style-2500+-black?&style=for-the-badge&label=Polls%20created)](https://samay.app/) 18 | [![Create a poll](https://shields.io/badge/style-Now-black?&style=for-the-badge&label=Create%20a%20poll)](https://samay.app/) 19 | 20 |

21 |
22 |
23 | 24 | Samay banner 25 | 26 |
27 | 28 | Samay is a free and open source group scheduling tool. Quickly find a time which works for everyone without the back-and-forth texts/emails! 29 | 30 | > #### Create a poll 31 | > 32 | > Select times you're free (click and drag), and optionally enter the title, description and location. The default poll type is "group" — to find a common time which works for everyone. If you want to have one-on-one meetings (parent-teacher meetings for example), select the "one-on-one" poll type. 33 | > 34 | > #### Share the poll 35 | > 36 | > Copy and share the poll link with the participants to let them mark their availability. In group polls, participants can either vote [yes] by clicking once or [if need be] by clicking twice. In one-on-one polls, participants can select their one preferred time. No login required. No time zone confusion since Samay automatically shows participants times in their local time zone. 37 | > 38 | > #### Book the meeting 39 | > 40 | > In group polls, find the most popular times and see who's free with [yes] votes - or who can be - with [if need be] votes, book the meeting and share the final time with the participants! In one-on-one polls, find who has chosen which time slot for a one-on-one with you! 41 | 42 | Create a poll now at [Samay.app](https://samay.app/)! 43 | 44 | ## Motivation 45 | 46 | After my GSoC '20 at LiberTEM, I wanted to have a video call with my mentors. They said yes, and since the next step was to find a suitable and common time, one of them sent me a link to a meeting poll created using a proprietary online service. It had surprisingly bad UX and was covered with advertisements. I searched for good, free and open source group scheduling tools, but didn't find any. So I decided to fix that problem. 47 | 48 | ## Get in touch 49 | 50 | If you have suggestions for how Samay could be improved or want to report an issue, check if a corresponding GitHub issue is already opened [here](https://github.com/anandbaburajan/samay/issues), otherwise open a new issue. 51 | 52 | ## Self-hosting 53 | 54 | ### Docker 55 | 56 | Coming soon! 57 | 58 | ### Vercel and MongoDB Atlas 59 | 60 | Samay is built with MongoDB and Next.js, so for a quick and free setup, you can use a free MongoDB Atlas cluster and Vercel's hobby plan. 61 | 62 | You can get started with MongoDB Atlas for free [here](https://www.mongodb.com/basics/mongodb-atlas-tutorial). Make sure to add all IP addresses (0.0.0.0/0) to the IP access list of your Atlas cluster since it is not possible to determine the IP addresses of Vercel deployments. 63 | 64 | You can get started with Vercel's hobby plan for free: 65 | 66 | 1. Fork this repo to your own GitHub account 67 | 2. Go to https://vercel.com/dashboard 68 | 3. Create a new project 69 | 4. Import your forked repository 70 | 5. Set the environment variables (according to the instructions in .env.example) 71 | 6. Deploy 72 | 73 | ## Contributing 74 | 75 | ### Development 76 | 77 | First, make sure you have [Node.js](https://nodejs.org/en/) and [MongoDB](https://www.mongodb.com/docs/manual/installation/#mongodb-installation-tutorials) installed. Then, to develop locally: 78 | 79 | 1. Fork this repo to your own GitHub account and then clone it. 80 | 81 | ```sh 82 | git clone https://github.com//samay.git 83 | ``` 84 | 85 | 2. Go to the project folder 86 | 87 | ```sh 88 | cd samay 89 | ``` 90 | 91 | 3. Create a new branch: 92 | 93 | ```sh 94 | git checkout -b MY_BRANCH_NAME 95 | ``` 96 | 97 | 4. Install the dependencies with: 98 | 99 | ```sh 100 | npm i 101 | ``` 102 | 103 | 5. Copy `.env.example` to `.env` 104 | 105 | ```sh 106 | cp .env.example .env 107 | ``` 108 | 109 | 6. Set the env variables according to the instructions in the .env file 110 | 111 | 7. Start developing and watch for code changes: 112 | 113 | ```sh 114 | npm run dev 115 | ``` 116 | 117 | 8. Please make sure that you can make a full production build before opening a PR. You can build the project with: 118 | 119 | ```sh 120 | npm run build 121 | ``` 122 | 123 | ## Acknowledgements 124 | 125 | Thanks to FOSS United for selecting Samay as one of the [winning projects](https://forum.fossunited.org/t/foss-hack-3-0-results/1882) at FOSS Hack 3.0. 126 | 127 | Thanks to these amazing projects which help power Samay: 128 | 129 | - React-big-calendar 130 | - React 131 | - Next.js 132 | - Day.js 133 | - Bootstrap 134 | - MongoDB 135 | - Mongoose 136 | - Inter 137 | - Cal Sans 138 | 139 | ## License 140 | 141 | Samay is distributed under the [MIT License](https://github.com/anandbaburajan/samay/blob/main/LICENSE). 142 | -------------------------------------------------------------------------------- /next.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | const nextConfig = { 3 | reactStrictMode: true, 4 | sassOptions: { 5 | includePaths: ["./styles"], 6 | }, 7 | eslint: { 8 | ignoreDuringBuilds: true, 9 | }, 10 | typescript: { 11 | ignoreBuildErrors: true, 12 | }, 13 | }; 14 | 15 | module.exports = nextConfig; 16 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "samay", 3 | "description": "Free and open source group scheduling tool", 4 | "version": "2.0.0", 5 | "scripts": { 6 | "dev": "NODE_OPTIONS='--inspect' next dev", 7 | "build": "next build", 8 | "start": "next start", 9 | "lint": "npx eslint . --ext .tsx,.ts" 10 | }, 11 | "repository": { 12 | "type": "git", 13 | "url": "https://github.com/samayapp/samay.git" 14 | }, 15 | "license": "MIT", 16 | "dependencies": { 17 | "@babel/core": "^7.12.10", 18 | "@fontsource/inter": "^5.0.17", 19 | "bootstrap": "^4.5.3", 20 | "cal-sans": "^1.0.1", 21 | "copy-to-clipboard": "^3.3.1", 22 | "dayjs": "^1.9.7", 23 | "mongoose": "^6.4.6", 24 | "nanoid": "^3.3.4", 25 | "next": "^12.1.0", 26 | "react": "^17.0.2", 27 | "react-big-calendar": "^1.6.8", 28 | "react-bootstrap": "^1.6.8", 29 | "react-bootstrap-icons": "^1.2.3", 30 | "react-dom": "^17.0.2", 31 | "react-toastify": "^9.1.1", 32 | "sass": "^1.49.11" 33 | }, 34 | "devDependencies": { 35 | "@types/mongoose": "^5.10.5", 36 | "@types/node": "^14.14.16", 37 | "@types/react": "^17.0.0", 38 | "@types/react-big-calendar": "^1.6.1", 39 | "@types/react-toastify": "^4.1.0", 40 | "@typescript-eslint/eslint-plugin": "^2.34.0", 41 | "@typescript-eslint/parser": "^2.34.0", 42 | "babel-core": "^6.26.3", 43 | "babel-jest": "^26.6.3", 44 | "babel-plugin-module-resolver": "^4.0.0", 45 | "babel-preset-env": "^1.7.0", 46 | "babel-preset-react": "^6.24.1", 47 | "eslint": "^6.8.0", 48 | "eslint-config-airbnb-typescript": "^7.2.1", 49 | "eslint-config-prettier": "^6.15.0", 50 | "eslint-import-resolver-babel-module": "^5.2.0", 51 | "eslint-plugin-import": "^2.22.1", 52 | "eslint-plugin-jsx-a11y": "^6.4.1", 53 | "eslint-plugin-prettier": "^3.1.4", 54 | "eslint-plugin-react": "^7.21.5", 55 | "eslint-plugin-react-hooks": "^2.5.1", 56 | "prettier": "^2.2.1", 57 | "typescript": "^4.6.2" 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /pages/_app.tsx: -------------------------------------------------------------------------------- 1 | import { AppProps } from "next/app"; 2 | import "cal-sans"; 3 | import "@fontsource/inter/400.css"; 4 | import "@fontsource/inter/500.css"; 5 | import "../src/styles/global.scss"; 6 | 7 | const App = ({ 8 | Component, 9 | pageProps: { session, ...pageProps }, 10 | }: AppProps): JSX.Element => { 11 | return ; 12 | }; 13 | 14 | export default App; 15 | -------------------------------------------------------------------------------- /pages/api/poll/[id].ts: -------------------------------------------------------------------------------- 1 | import { NextApiRequest, NextApiResponse } from "next"; 2 | import SamayPoll, { Vote, PollDoc } from "../../../src/models/poll"; 3 | import { isTimePresentInPollTimes } from "../../../src/helpers"; 4 | import connectToDatabase from "../../../src/utils/db"; 5 | 6 | export default async ( 7 | req: NextApiRequest, 8 | res: NextApiResponse 9 | ): Promise => { 10 | const { 11 | query: { id }, 12 | method, 13 | body, 14 | } = req; 15 | 16 | switch (method) { 17 | case "GET": 18 | try { 19 | await connectToDatabase(); 20 | const poll: PollDoc | null = await SamayPoll.findOne({ 21 | _id: id, 22 | }).lean(); 23 | if (!poll) { 24 | res.status(404).json({ message: "Poll does not exist" }); 25 | } else { 26 | res.status(200).json(poll); 27 | } 28 | } catch (err) { 29 | res.status(404).json({ message: err.message }); 30 | } 31 | break; 32 | case "PUT": 33 | try { 34 | await connectToDatabase(); 35 | const poll: PollDoc | null = await SamayPoll.findOne({ 36 | _id: id, 37 | }); 38 | if (poll) { 39 | const vote: Vote = JSON.parse(body); 40 | if (!poll.open) { 41 | res.status(400).json({ message: "Poll closed" }); 42 | } else if ( 43 | !vote.times.every((time) => 44 | isTimePresentInPollTimes(time, poll.times) 45 | ) 46 | ) { 47 | res.status(400).json({ message: "Invalid times" }); 48 | } else { 49 | const currentVotes: Vote[] | undefined = poll.votes; 50 | let newVotes: Vote[] | undefined; 51 | 52 | if (currentVotes && currentVotes?.length > 0) { 53 | newVotes = currentVotes; 54 | newVotes.push(vote); 55 | } else { 56 | newVotes = [ 57 | { 58 | name: vote.name, 59 | times: vote.times, 60 | }, 61 | ]; 62 | } 63 | const updatedPoll: PollDoc | null = await SamayPoll.findOneAndUpdate( 64 | { _id: id }, 65 | { votes: newVotes }, 66 | { new: true } 67 | ); 68 | res.status(201).json(updatedPoll); 69 | } 70 | } else { 71 | res.status(404).json({ message: "Poll does not exist" }); 72 | } 73 | } catch (err) { 74 | res.status(400).json({ message: err.message }); 75 | } 76 | break; 77 | default: 78 | res.setHeader("Allow", ["GET", "PUT"]); 79 | res.status(405).end(`Method ${method} Not Allowed`); 80 | } 81 | }; 82 | -------------------------------------------------------------------------------- /pages/api/poll/[id]/[secret].ts: -------------------------------------------------------------------------------- 1 | import { NextApiRequest, NextApiResponse } from "next"; 2 | import SamayPoll, { PollDoc } from "../../../../src/models/poll"; 3 | import connectToDatabase from "../../../../src/utils/db"; 4 | 5 | export default async ( 6 | req: NextApiRequest, 7 | res: NextApiResponse 8 | ): Promise => { 9 | const { 10 | query: { id, secret }, 11 | method, 12 | body, 13 | } = req; 14 | 15 | switch (method) { 16 | case "PUT": 17 | try { 18 | await connectToDatabase(); 19 | const poll: PollDoc | null = await SamayPoll.findOne({ 20 | _id: id, 21 | }).lean(); 22 | if (poll) { 23 | if (poll.secret !== secret) { 24 | res.status(403).json({ message: "Forbidden" }); 25 | } else { 26 | try { 27 | const updatedPoll: PollDoc | null = await SamayPoll.findOneAndUpdate( 28 | { _id: id }, 29 | JSON.parse(body), 30 | { new: true } 31 | ); 32 | res.status(201).json(updatedPoll); 33 | } catch (err) { 34 | res.status(400).json({ message: err.message }); 35 | } 36 | } 37 | } else { 38 | res.status(404).json({ message: "Poll not found" }); 39 | } 40 | } catch (err) { 41 | res.status(400).json({ message: err.message }); 42 | } 43 | break; 44 | case "DELETE": 45 | try { 46 | await connectToDatabase(); 47 | const poll: PollDoc | null = await SamayPoll.findOne({ 48 | _id: id, 49 | }).lean(); 50 | if (poll) { 51 | if (poll.secret !== secret) { 52 | res.status(403).json({ message: "Forbidden" }); 53 | } else { 54 | try { 55 | const deletedPoll = await SamayPoll.findByIdAndRemove(id); 56 | res.status(200).json(deletedPoll); 57 | } catch (err) { 58 | res.status(400).json({ message: err.message }); 59 | } 60 | } 61 | } else { 62 | res.status(404).json({ message: "Poll not found" }); 63 | } 64 | } catch (err) { 65 | res.status(400).json({ message: err.message }); 66 | } 67 | break; 68 | default: 69 | res.setHeader("Allow", ["PUT", "DELETE"]); 70 | res.status(405).end(`Method ${method} Not Allowed`); 71 | } 72 | }; 73 | -------------------------------------------------------------------------------- /pages/api/poll/create.ts: -------------------------------------------------------------------------------- 1 | import { NextApiRequest, NextApiResponse } from "next"; 2 | import SamayPoll, { PollDoc } from "../../../src/models/poll"; 3 | import connectToDatabase from "../../../src/utils/db"; 4 | 5 | export default async ( 6 | req: NextApiRequest, 7 | res: NextApiResponse 8 | ): Promise => { 9 | const { method, body } = req; 10 | 11 | switch (method) { 12 | case "POST": 13 | try { 14 | await connectToDatabase(); 15 | const newPoll: PollDoc = new SamayPoll(JSON.parse(body)); 16 | await newPoll.save(); 17 | res.status(201).json(newPoll); 18 | } catch (err) { 19 | res.status(400).json({ message: err.message }); 20 | } 21 | break; 22 | default: 23 | res.setHeader("Allow", ["POST"]); 24 | res.status(405).end(`Method ${method} Not Allowed`); 25 | } 26 | }; 27 | -------------------------------------------------------------------------------- /pages/how-to-vote.tsx: -------------------------------------------------------------------------------- 1 | import Head from "next/head"; 2 | import { useRouter } from "next/router"; 3 | import { Card, CardGroup, Container } from "react-bootstrap"; 4 | import { CheckCircleFill, CircleFill } from "react-bootstrap-icons"; 5 | import Layout from "../src/components/Layout"; 6 | 7 | const HowTo = (): JSX.Element => { 8 | if (typeof window !== "undefined") { 9 | localStorage.setItem("samayNewVisitor", JSON.stringify(false)); 10 | } 11 | 12 | const router = useRouter(); 13 | 14 | return ( 15 | <> 16 | 17 | Samay — how-to 18 | 19 | 20 | 21 | 22 | 26 | 30 | 31 | 32 | 36 | 40 | 41 | 42 | 43 | 47 | 51 | 52 | 53 | 54 | 55 | 56 | Samay — free and open source group scheduling tool 57 | 58 | 59 | Find a time which works for everyone without the back-and-forth 60 | texts/emails! 61 | 62 | 63 | 64 | 65 | 66 | How to vote 67 | 68 | 69 | In group polls, select all time slots you're available at. You 70 | can vote "yes" [ 71 | ] by 72 | clicking a slot once or "if need be" [ 73 | ] by 74 | clicking twice. Clicking again would remove the vote. 75 |
76 |
77 | In one-on-one polls, you can select your one preferred time. 78 |
79 |
80 | No login required. 81 |
82 |
83 |
84 |
85 |
86 |
87 | 88 | ); 89 | }; 90 | 91 | export default HowTo; 92 | -------------------------------------------------------------------------------- /pages/how-to.tsx: -------------------------------------------------------------------------------- 1 | import Head from "next/head"; 2 | import Link from "next/link"; 3 | import { Button, Card, CardGroup, Container } from "react-bootstrap"; 4 | import { CheckCircleFill, CircleFill } from "react-bootstrap-icons"; 5 | import Layout from "../src/components/Layout"; 6 | 7 | const HowTo = (): JSX.Element => { 8 | if (typeof window !== "undefined") { 9 | localStorage.setItem("samayNewVisitor", JSON.stringify(false)); 10 | } 11 | 12 | return ( 13 | <> 14 | 15 | Samay — how-to 16 | 17 | 18 | 19 | 20 | 24 | 28 | 29 | 30 | 34 | 38 | 39 | 40 | 41 | 45 | 49 | 50 | 51 | 52 | 53 | 54 | Samay — free and open source group scheduling tool 55 | 56 | 57 | Find a time which works for everyone without the back-and-forth 58 | texts/emails! 59 | 60 | 61 | 62 | 63 | 64 | 1. Create a poll 65 | 66 | 67 | Select times you're free (click and drag), and optionally 68 | enter the title, description and location. No login required. 69 |
70 |
71 | The default poll type is "group" — to find a common time which 72 | works for everyone. If you want to have one-on-one meetings 73 | (parent-teacher meetings for example), select the "one-on-one" 74 | poll type. 75 |
76 |
77 |
78 |
79 | 80 | 81 | 82 | 83 | 2. Share the poll 84 | 85 | 86 | Copy and share the poll link with the participants to let them 87 | mark their availability. No time zone confusion since Samay 88 | automatically shows participants times in their local time 89 | zone. 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 3. Book the meeting 99 | 100 | 101 | In group polls, find the most popular times and see who's free 102 | with "yes" [ 103 | ] votes or 104 | who can be with "if need be" [ 105 | ] votes, 106 | book the meeting and share the final time with the 107 | participants! In one-on-one polls, find who has chosen which 108 | time slot for a one-on-one with you! 109 | 110 | 111 | 112 | 113 |
114 | 115 | 116 | 117 | 118 | 119 |
120 | 121 | ); 122 | }; 123 | 124 | export default HowTo; 125 | -------------------------------------------------------------------------------- /pages/index.tsx: -------------------------------------------------------------------------------- 1 | import { nanoid } from "nanoid"; 2 | import Head from "next/head"; 3 | import Router from "next/router"; 4 | import { useState } from "react"; 5 | import { 6 | Button, 7 | Col, 8 | Container, 9 | Form, 10 | Jumbotron, 11 | Row, 12 | Spinner, 13 | } from "react-bootstrap"; 14 | import { ToastContainer, toast } from "react-toastify"; 15 | import Layout from "../src/components/Layout"; 16 | import SamayRBC from "../src/components/SamayRBC"; 17 | import { encrypt } from "../src/helpers"; 18 | import toastOptions from "../src/helpers/toastOptions"; 19 | import { Poll, Time } from "../src/models/poll"; 20 | import { createPoll } from "../src/utils/api/server"; 21 | 22 | const Home = (): JSX.Element => { 23 | const [pollDetails, setPollDetails] = useState<{ 24 | pollTitle: string; 25 | pollLocation: string; 26 | pollDescription: string; 27 | }>({ 28 | pollTitle: "", 29 | pollLocation: "", 30 | pollDescription: "", 31 | }); 32 | 33 | const [pollType, setPollType] = useState("group"); 34 | 35 | const { pollTitle, pollLocation, pollDescription } = pollDetails; 36 | 37 | const [pollTimes, setTimes] = useState([]); 38 | const [disabled, setDisabled] = useState(false); 39 | 40 | const handlePollDetailsChange = ( 41 | e: React.ChangeEvent 42 | ): void => { 43 | const { name, value } = e.target; 44 | 45 | setPollDetails({ 46 | ...pollDetails, 47 | [name]: value, 48 | }); 49 | }; 50 | 51 | const handlePollTypeChange = ( 52 | e: React.ChangeEvent 53 | ): void => { 54 | setPollType(e.target.value); 55 | }; 56 | 57 | const handleSubmit = async ( 58 | e: React.MouseEvent 59 | ): Promise => { 60 | e.preventDefault(); 61 | 62 | if (!pollTimes || (pollTimes && pollTimes?.length < 2)) { 63 | toast.error("Please select at least two time slots", toastOptions); 64 | return; 65 | } 66 | 67 | const secret = nanoid(10); 68 | const encryptedSecret = encrypt(secret); 69 | 70 | const poll: Poll = { 71 | title: pollTitle, 72 | description: pollDescription, 73 | location: pollLocation, 74 | type: pollType, 75 | secret: encryptedSecret, 76 | times: pollTimes, 77 | }; 78 | 79 | try { 80 | setDisabled(true); 81 | 82 | const createPollResponse = await createPoll({ 83 | poll, 84 | }); 85 | 86 | if (createPollResponse.statusCode === 201) { 87 | if (typeof window !== "undefined") { 88 | const samayCreatedPolls = localStorage.getItem("samayCreatedPolls"); 89 | 90 | if (!samayCreatedPolls) { 91 | const initSamayCreatedPolls = { 92 | polls: [ 93 | { 94 | [`${createPollResponse.data._id}-${pollTitle}`]: `${encryptedSecret}`, 95 | }, 96 | ], 97 | }; 98 | 99 | localStorage.setItem( 100 | "samayCreatedPolls", 101 | JSON.stringify(initSamayCreatedPolls) 102 | ); 103 | } else { 104 | let samayCreatedPollsJSON = JSON.parse(samayCreatedPolls); 105 | 106 | samayCreatedPollsJSON.polls.push({ 107 | [`${createPollResponse.data._id}-${pollTitle}`]: `${encryptedSecret}`, 108 | }); 109 | 110 | localStorage.setItem( 111 | "samayCreatedPolls", 112 | JSON.stringify(samayCreatedPollsJSON) 113 | ); 114 | } 115 | } 116 | Router.push(`/poll/${createPollResponse.data._id}/${secret}`); 117 | } else { 118 | setDisabled(false); 119 | toast.error( 120 | "Poll creation failed, please try again later", 121 | toastOptions 122 | ); 123 | } 124 | } catch (err) { 125 | setDisabled(false); 126 | toast.error("Poll creation failed, please try again later", toastOptions); 127 | } 128 | }; 129 | 130 | return ( 131 | <> 132 | 133 | Samay — find a time which works for everyone 134 | 135 | 136 | 137 | 141 | 145 | 146 | 147 | 151 | 155 | 156 | 157 | 158 | 162 | 166 | 167 | 168 | 169 |
170 | 171 | 172 | 173 | 174 | 175 | 176 | 177 | 185 | 186 | 187 | 195 | 196 | 197 | 205 | 206 | 207 | 208 | 215 | 216 | 217 | 218 | 219 | 220 | 221 | 241 | 242 | 243 | 244 | 245 | 246 |
247 |
248 | 249 | ); 250 | }; 251 | 252 | export default Home; 253 | -------------------------------------------------------------------------------- /pages/poll/[id].tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from "react"; 2 | import Router from "next/router"; 3 | import Head from "next/head"; 4 | import { GetServerSideProps } from "next"; 5 | import { Container, Jumbotron, Form } from "react-bootstrap"; 6 | import dayjs from "dayjs"; 7 | import localizedFormat from "dayjs/plugin/localizedFormat"; 8 | import { getPoll } from "../../src/utils/api/server"; 9 | import VoterPollInfo from "../../src/components/poll/VoterPollInfo"; 10 | import PollTableVoter from "../../src/components/poll/PollTableVoter"; 11 | import SubmitTimes from "../../src/components/poll/SubmitTimes"; 12 | import Layout from "../../src/components/Layout"; 13 | import { TimeFromDB, Vote, PollFromDB } from "../../src/models/poll"; 14 | import { decrypt } from "../../src/helpers"; 15 | 16 | dayjs.extend(localizedFormat); 17 | 18 | const Poll = (props: { 19 | pollFromDB: PollFromDB; 20 | pollID: string; 21 | }): JSX.Element => { 22 | const { pollFromDB, pollID } = props; 23 | 24 | let pageSection = <>; 25 | 26 | const sortedTimes: TimeFromDB[] = pollFromDB.times.sort( 27 | (a: TimeFromDB, b: TimeFromDB) => a.start - b.start 28 | ); 29 | 30 | const [newVote, setNewVote] = useState({ 31 | name: "", 32 | times: [], 33 | }); 34 | 35 | let showVoteRecordedGroup = false; 36 | let showVoteRecordedOneOnOne = false; 37 | let votedTimeOneOnOne = null; 38 | 39 | const handleNameChange = (e: React.ChangeEvent): void => { 40 | const { value } = e.target; 41 | setNewVote({ name: value, times: newVote.times }); 42 | }; 43 | 44 | if (typeof window !== "undefined") { 45 | let createdPollsFromLS = JSON.parse( 46 | localStorage.getItem("samayCreatedPolls") 47 | ); 48 | 49 | if (createdPollsFromLS && createdPollsFromLS.polls.length) { 50 | const lSKeyForPoll = `${pollID}-${ 51 | pollFromDB.title ? pollFromDB.title : "" 52 | }`; 53 | 54 | for (let i = 0; i < createdPollsFromLS.polls.length; i += 1) { 55 | let poll = createdPollsFromLS.polls[i]; 56 | 57 | if ( 58 | Object.keys(poll)[0] === lSKeyForPoll && 59 | poll[Object.keys(poll)[0]] === pollFromDB.secret 60 | ) { 61 | Router.push(`/poll/${pollID}/${decrypt(pollFromDB.secret)}`); 62 | return <>; 63 | } 64 | } 65 | } 66 | 67 | let votedPollsFromLS = JSON.parse(localStorage.getItem("samayVotedPolls")); 68 | 69 | if (votedPollsFromLS && votedPollsFromLS.polls.length) { 70 | for (let i = 0; i < votedPollsFromLS.polls.length; i += 1) { 71 | let poll = votedPollsFromLS.polls[i]; 72 | 73 | if (Object.keys(poll)[0] === pollID && pollFromDB.open) { 74 | pageSection = <>; 75 | if (!pollFromDB.type || pollFromDB.type === "group") { 76 | showVoteRecordedGroup = true; 77 | } else { 78 | votedTimeOneOnOne = JSON.parse( 79 | (Object.values(poll)[0] as string).split("#")[1] 80 | ); 81 | showVoteRecordedOneOnOne = true; 82 | } 83 | break; 84 | } else if (pollFromDB.open) { 85 | pageSection = ( 86 | <> 87 |
88 | 94 |
95 |
96 | 104 | 109 |
110 | 111 | ); 112 | } 113 | } 114 | } else if (pollFromDB.open) { 115 | pageSection = ( 116 | <> 117 |
118 | 124 |
125 |
126 | 134 | 139 |
140 | 141 | ); 142 | } 143 | } 144 | 145 | return ( 146 | <> 147 | 148 | Samay — mark your availability 149 | 150 | 151 | 152 | 153 | 154 |
155 | 156 | 157 | 164 | 165 | {pageSection} 166 | 167 |
168 |
169 | 170 | ); 171 | }; 172 | 173 | export const getServerSideProps: GetServerSideProps = async (context) => { 174 | let pollID = null; 175 | if (context.params) { 176 | pollID = context.params.id; 177 | } 178 | const getPollResponse = await getPoll(pollID); 179 | const pollFromDB = getPollResponse.data; 180 | 181 | if (getPollResponse.statusCode === 404) { 182 | return { 183 | redirect: { 184 | destination: "/404", 185 | permanent: false, 186 | }, 187 | }; 188 | } 189 | 190 | return { 191 | props: { pollFromDB, pollID }, // will be passed to the page component as props 192 | }; 193 | }; 194 | 195 | export default Poll; 196 | -------------------------------------------------------------------------------- /pages/poll/[id]/[secret].tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from "react"; 2 | import Head from "next/head"; 3 | import { GetServerSideProps } from "next"; 4 | import { Container, Jumbotron } from "react-bootstrap"; 5 | import dayjs from "dayjs"; 6 | import localizedFormat from "dayjs/plugin/localizedFormat"; 7 | import { getPoll } from "../../../src/utils/api/server"; 8 | import Layout from "../../../src/components/Layout"; 9 | import AdminPollInfo from "../../../src/components/poll/AdminPollInfo"; 10 | import PollTableAdmin from "../../../src/components/poll/PollTableAdmin"; 11 | import SubmitFinalTime from "../../../src/components/poll/SubmitFinalTime"; 12 | import { Time, TimeFromDB, PollFromDB } from "../../../src/models/poll"; 13 | import { decrypt } from "../../../src/helpers"; 14 | 15 | dayjs.extend(localizedFormat); 16 | 17 | const Poll = (props: { 18 | pollFromDB: PollFromDB; 19 | pollID: string; 20 | secret: string; 21 | }): JSX.Element => { 22 | const { pollFromDB, pollID, secret } = props; 23 | const sortedTimes: TimeFromDB[] = pollFromDB.times.sort( 24 | (a: TimeFromDB, b: TimeFromDB) => a.start - b.start 25 | ); 26 | const [finalTime, setFinalTime] = useState