├── .eslintrc.json
├── .vscode
└── settings.json
├── postcss.config.js
├── utils
├── picture-utils.js
├── lazyload-config.js
├── get-stripe.js
├── enforce-api-route-secret.js
├── weight-utils.js
├── wii-balance-board
│ ├── helpers.js
│ ├── const.js
│ └── WiiBalanceBoard.js
├── exercise-utils.js
├── send-email.js
├── subscription-utils.js
├── EventDispatcher.js
├── supabase.js
├── MiSmartScale2.js
├── MiBodyCompositionScale.js
├── MIBCSMetrics.js
└── withings.js
├── public
├── favicon.ico
├── images
│ ├── logo.png
│ ├── icon-192x192.png
│ ├── icon-256x256.png
│ ├── icon-384x384.png
│ ├── icon-512x512.png
│ ├── icon-96x96.png
│ ├── apple-touch-icon.png
│ └── icon.svg
├── features
│ ├── screenshot_1.png
│ ├── screenshot_2.png
│ ├── screenshot_3.png
│ ├── screenshot_4.png
│ ├── screenshot_5.png
│ ├── screenshot_6.png
│ └── screenshot_7.png
├── fallback-GtbOZAaAkPqSfUfS3cKdI.js
├── manifest-ios.json
└── manifest.json
├── prettier.config.js
├── components
├── MyLink.jsx
├── dashboard
│ ├── notification
│ │ └── DeleteAccountNotification.jsx
│ ├── modal
│ │ ├── DeleteAccountModal.jsx
│ │ ├── DeleteUserModal.jsx
│ │ ├── DeleteSubscriptionModal.jsx
│ │ ├── DeletePictureModal.jsx
│ │ ├── DeleteWeightModal.jsx
│ │ ├── DeleteExerciseTypeModal.jsx
│ │ ├── DeleteExerciseModal.jsx
│ │ └── DeleteBlockModal.jsx
│ └── ClientsSelect.jsx
├── layouts
│ ├── Layout.jsx
│ └── DashboardLayout.jsx
├── GoogleDriveVideo.jsx
├── QRCodeModal.jsx
├── OfflineBanner.jsx
├── LazyImage.jsx
├── LazyVideo.jsx
├── Notification.jsx
├── ExerciseTypeVideo.jsx
├── Modal.jsx
└── Footer.jsx
├── styles
└── index.css
├── .gitignore
├── tailwind.config.js
├── next.config.js
├── context
├── progress-context.jsx
├── online-context.jsx
├── exercise-types-context.jsx
├── coach-picture-context.jsx
├── exercise-videos-context.jsx
├── selected-exercise-context.jsx
├── picture-context.jsx
├── wii-balance-board-context.jsx
└── user-context.jsx
├── .env.local.example
├── pages
├── subscription
│ └── [id].jsx
├── _offline.jsx
├── api
│ ├── account
│ │ ├── stripe-dashboard.js
│ │ ├── stripe-customer-portal.js
│ │ ├── stripe-onboarding.js
│ │ ├── setup-account.js
│ │ ├── set-withings-auth-code.js
│ │ ├── refresh-withings-access-token.js
│ │ ├── get-withings-access-token.js
│ │ ├── set-notifications.js
│ │ ├── check-stripe.js
│ │ └── sign-in.js
│ ├── webhook
│ │ ├── stripe
│ │ │ └── account.js
│ │ └── withings
│ │ │ └── data.js
│ └── subscription
│ │ ├── delete-subscription.js
│ │ ├── redeem-subscription.js
│ │ └── create-subscription.js
├── 404.jsx
├── terms.jsx
├── _document.jsx
├── _app.jsx
├── privacy.jsx
├── faq.jsx
└── dashboard
│ ├── blocks.jsx
│ ├── my-clients.jsx
│ ├── my-coaches.jsx
│ ├── all-users.jsx
│ └── bodyweight.jsx
├── LICENSE
├── README.md
└── package.json
/.eslintrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "next/core-web-vitals"
3 | }
4 |
--------------------------------------------------------------------------------
/.vscode/settings.json:
--------------------------------------------------------------------------------
1 | {
2 | "editor.formatOnSave": true
3 | }
--------------------------------------------------------------------------------
/postcss.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | plugins: ["tailwindcss"],
3 | };
4 |
--------------------------------------------------------------------------------
/utils/picture-utils.js:
--------------------------------------------------------------------------------
1 | export const pictureTypes = ["front", "side", "back"];
2 |
--------------------------------------------------------------------------------
/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/zakaton/repsetter/HEAD/public/favicon.ico
--------------------------------------------------------------------------------
/prettier.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | tailwindConfig: './tailwind.config.js',
3 | };
4 |
--------------------------------------------------------------------------------
/public/images/logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/zakaton/repsetter/HEAD/public/images/logo.png
--------------------------------------------------------------------------------
/public/images/icon-192x192.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/zakaton/repsetter/HEAD/public/images/icon-192x192.png
--------------------------------------------------------------------------------
/public/images/icon-256x256.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/zakaton/repsetter/HEAD/public/images/icon-256x256.png
--------------------------------------------------------------------------------
/public/images/icon-384x384.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/zakaton/repsetter/HEAD/public/images/icon-384x384.png
--------------------------------------------------------------------------------
/public/images/icon-512x512.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/zakaton/repsetter/HEAD/public/images/icon-512x512.png
--------------------------------------------------------------------------------
/public/images/icon-96x96.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/zakaton/repsetter/HEAD/public/images/icon-96x96.png
--------------------------------------------------------------------------------
/public/features/screenshot_1.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/zakaton/repsetter/HEAD/public/features/screenshot_1.png
--------------------------------------------------------------------------------
/public/features/screenshot_2.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/zakaton/repsetter/HEAD/public/features/screenshot_2.png
--------------------------------------------------------------------------------
/public/features/screenshot_3.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/zakaton/repsetter/HEAD/public/features/screenshot_3.png
--------------------------------------------------------------------------------
/public/features/screenshot_4.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/zakaton/repsetter/HEAD/public/features/screenshot_4.png
--------------------------------------------------------------------------------
/public/features/screenshot_5.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/zakaton/repsetter/HEAD/public/features/screenshot_5.png
--------------------------------------------------------------------------------
/public/features/screenshot_6.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/zakaton/repsetter/HEAD/public/features/screenshot_6.png
--------------------------------------------------------------------------------
/public/features/screenshot_7.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/zakaton/repsetter/HEAD/public/features/screenshot_7.png
--------------------------------------------------------------------------------
/public/images/apple-touch-icon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/zakaton/repsetter/HEAD/public/images/apple-touch-icon.png
--------------------------------------------------------------------------------
/public/fallback-GtbOZAaAkPqSfUfS3cKdI.js:
--------------------------------------------------------------------------------
1 | (()=>{"use strict";self.fallback=async e=>"document"===e.destination?caches.match("/_offline",{ignoreSearch:!0}):Response.error()})();
--------------------------------------------------------------------------------
/utils/lazyload-config.js:
--------------------------------------------------------------------------------
1 | const config = {
2 | elements_selector: ".lazy",
3 | threshold: 100,
4 | callback_enter: (element) => {
5 | console.log("enter", element);
6 | },
7 | callback_exit: (element) => {
8 | console.log("Exit", element);
9 | },
10 | };
11 | export default config;
12 |
--------------------------------------------------------------------------------
/utils/get-stripe.js:
--------------------------------------------------------------------------------
1 | import { loadStripe } from '@stripe/stripe-js';
2 |
3 | let stripePromise;
4 | const getStripe = () => {
5 | if (!stripePromise) {
6 | stripePromise = loadStripe(process.env.NEXT_PUBLIC_STRIPE_KEY);
7 | }
8 | return stripePromise;
9 | };
10 |
11 | export default getStripe;
12 |
--------------------------------------------------------------------------------
/utils/enforce-api-route-secret.js:
--------------------------------------------------------------------------------
1 | export default function enforceApiRouteSecret(req, res) {
2 | if (
3 | (req.query.API_ROUTE_SECRET || req.body.API_ROUTE_SECRET) !==
4 | process.env.API_ROUTE_SECRET
5 | ) {
6 | res.status(401).send('You are not authorized to make this call');
7 | return false;
8 | }
9 |
10 | return true;
11 | }
12 |
--------------------------------------------------------------------------------
/components/MyLink.jsx:
--------------------------------------------------------------------------------
1 | import { forwardRef } from 'react';
2 | import Link from 'next/link';
3 |
4 | const MyLink = forwardRef((props, ref) => {
5 | const { href, as, children, ...rest } = props;
6 | return (
7 |
8 |
9 | {children}
10 |
11 |
12 | );
13 | });
14 | MyLink.displayName = 'MyLink';
15 |
16 | export default MyLink;
17 |
--------------------------------------------------------------------------------
/utils/weight-utils.js:
--------------------------------------------------------------------------------
1 | export const weightEvents = [
2 | { name: "none", color: "rgb(255, 191, 102)" },
3 | { name: "changed clothes", color: "rgb(240, 15, 247)" },
4 | { name: "ate", color: "rgb(83, 242, 78)" },
5 | { name: "drank", color: "rgb(73, 242, 234)" },
6 | { name: "urinated", color: "rgb(252, 255, 59)" },
7 | { name: "pooped", color: "rgb(117, 86, 0)" },
8 | { name: "worked out", color: "rgb(255, 81, 69)" },
9 | ];
10 |
11 | export let weightEventColors = {};
12 | weightEvents.forEach(({ name, color }) => (weightEventColors[name] = color));
13 |
--------------------------------------------------------------------------------
/styles/index.css:
--------------------------------------------------------------------------------
1 | @tailwind base;
2 | @tailwind components;
3 | @tailwind utilities;
4 |
5 | @layer base {
6 | .style-links a {
7 | @apply text-blue-600 no-underline;
8 | }
9 | .style-links a:hover {
10 | @apply text-blue-500;
11 | }
12 |
13 | /* Chrome, Safari, Edge, Opera */
14 | input.hide-arrows::-webkit-outer-spin-button,
15 | input.hide-arrows::-webkit-inner-spin-button {
16 | -webkit-appearance: none;
17 | margin: 0;
18 | }
19 |
20 | /* Firefox */
21 | input.hide-arrows[type=number] {
22 | -moz-appearance: textfield;
23 | }
24 | }
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
2 |
3 | # dependencies
4 | /node_modules
5 | /.pnp
6 | .pnp.js
7 |
8 | # testing
9 | /coverage
10 |
11 | # next.js
12 | /.next/
13 | /out/
14 |
15 | # production
16 | /build
17 |
18 | # misc
19 | .DS_Store
20 | *.pem
21 |
22 | # debug
23 | npm-debug.log*
24 | yarn-debug.log*
25 | yarn-error.log*
26 |
27 | # local env files
28 | .env.local
29 | .env.development.local
30 | .env.test.local
31 | .env.production.local
32 |
33 | # vercel
34 | .vercel
35 |
36 | # TODO
37 | TODO
38 |
39 | # MISC
40 | *.txt
41 | repsetter.code-workspace
42 |
--------------------------------------------------------------------------------
/tailwind.config.js:
--------------------------------------------------------------------------------
1 | const defaultTheme = require("tailwindcss/defaultTheme");
2 |
3 | module.exports = {
4 | content: [
5 | "./pages/**/*.{js,ts,jsx,tsx}",
6 | "./components/**/*.{js,ts,jsx,tsx}",
7 | ],
8 | theme: {
9 | screens: {
10 | xxs: "260px",
11 | xs: "360px",
12 | ...defaultTheme.screens,
13 | },
14 | extend: {
15 | fontFamily: {
16 | sans: ["Inter var", ...defaultTheme.fontFamily.sans],
17 | },
18 | },
19 | },
20 | plugins: [
21 | require("@tailwindcss/typography"),
22 | require("@tailwindcss/forms"),
23 | require("@tailwindcss/aspect-ratio"),
24 | ],
25 | };
26 |
--------------------------------------------------------------------------------
/components/dashboard/notification/DeleteAccountNotification.jsx:
--------------------------------------------------------------------------------
1 | import { useEffect, useState } from 'react';
2 | import { useUser } from '../../../context/user-context';
3 | import Notification from '../../Notification';
4 |
5 | export default function DeleteAccountNotification() {
6 | const { didDeleteAccount } = useUser();
7 | const [open, setOpen] = useState(false);
8 | const [status, setStatus] = useState();
9 |
10 | useEffect(() => {
11 | if (didDeleteAccount) {
12 | setOpen(true);
13 | setStatus({ type: 'succeeded', title: 'Successfully deleted account' });
14 | }
15 | }, [didDeleteAccount]);
16 |
17 | return ;
18 | }
19 |
--------------------------------------------------------------------------------
/public/images/icon.svg:
--------------------------------------------------------------------------------
1 |
2 | Repsetter Logo
3 |
7 |
8 |
9 |
10 |
11 |
12 |
--------------------------------------------------------------------------------
/next.config.js:
--------------------------------------------------------------------------------
1 | const withPWA = require("next-pwa");
2 |
3 | /** @type {import('next').NextConfig} */
4 | const nextConfig = withPWA({
5 | reactStrictMode: false,
6 | images: {
7 | domains: [process.env.NEXT_PUBLIC_SUPABASE_URL.split("//")[1]],
8 | },
9 | pwa: {
10 | dest: "public",
11 | register: true,
12 | skipWaiting: true,
13 | disable: process.env.NODE_ENV === "development",
14 | },
15 | i18n: {
16 | locales: ["en"],
17 | defaultLocale: "en",
18 | },
19 | compiler:
20 | process.env.NODE_ENV === "production"
21 | ? {
22 | removeConsole: {
23 | exclude: ["error"],
24 | },
25 | }
26 | : {},
27 | });
28 |
29 | module.exports = nextConfig;
30 |
--------------------------------------------------------------------------------
/utils/wii-balance-board/helpers.js:
--------------------------------------------------------------------------------
1 | export function toBigEndian(n, size) {
2 | var buffer = new Array();
3 |
4 | n.toString(16)
5 | .match(/.{1,2}/g)
6 | ?.map((x) => {
7 | var v = "0x" + x;
8 | var a = Number(v);
9 | buffer.push(a);
10 | });
11 |
12 | return buffer;
13 | }
14 |
15 | export function numbersToBuffer(data) {
16 | return new Int8Array(data);
17 | }
18 |
19 | export function debug(buffer, print = true) {
20 | let a = Array.prototype.map
21 | .call(new Uint8Array(buffer), (x) => ("00" + x.toString(16)).slice(-2))
22 | .join("-");
23 | if (print) console.log(a);
24 | return a;
25 | }
26 |
27 | export function getBitInByte(byte, index) {
28 | return byte & (1 << (index - 1));
29 | }
30 |
--------------------------------------------------------------------------------
/context/progress-context.jsx:
--------------------------------------------------------------------------------
1 | import { useState, createContext, useContext } from "react";
2 |
3 | export const ProgressContext = createContext();
4 |
5 | export function ProgressContextProvider(props) {
6 | const [progressFilters, setProgressFilters] = useState({
7 | "date-range": "past month",
8 | });
9 | const [progressContainsFilters, setProgressContainsFilters] = useState({});
10 |
11 | const value = {
12 | progressFilters,
13 | setProgressFilters,
14 | progressContainsFilters,
15 | setProgressContainsFilters,
16 | };
17 | return ;
18 | }
19 |
20 | export function useProgress() {
21 | const context = useContext(ProgressContext);
22 | return context;
23 | }
24 |
--------------------------------------------------------------------------------
/components/layouts/Layout.jsx:
--------------------------------------------------------------------------------
1 | import Head from "next/head";
2 | import Header from "../Header";
3 | import Footer from "../Footer";
4 |
5 | import { useOnline } from "../../context/online-context";
6 | import OfflineBanner from "../OfflineBanner";
7 | import DeleteAccountNotification from "../dashboard/notification/DeleteAccountNotification";
8 |
9 | export default function Layout({ children }) {
10 | const { online } = useOnline();
11 | return (
12 | <>
13 |
14 | Repsetter
15 |
16 |
17 | {!online && }
18 |
19 | {children}
20 |
21 | >
22 | );
23 | }
24 |
--------------------------------------------------------------------------------
/context/online-context.jsx:
--------------------------------------------------------------------------------
1 | import { useEffect, useState, createContext, useContext } from 'react';
2 |
3 | export const OnlineContext = createContext();
4 |
5 | export function OnlineContextProvider(props) {
6 | const [online, setOnline] = useState(true);
7 |
8 | useEffect(() => {
9 | const online = navigator.onLine;
10 | setOnline(online);
11 |
12 | window.ononline = () => {
13 | setOnline(true);
14 | };
15 | window.onoffline = () => {
16 | setOnline(false);
17 | };
18 | }, []);
19 |
20 | const value = { online };
21 |
22 | return ;
23 | }
24 |
25 | export function useOnline() {
26 | const context = useContext(OnlineContext);
27 | return context;
28 | }
29 |
--------------------------------------------------------------------------------
/.env.local.example:
--------------------------------------------------------------------------------
1 | NEXT_PUBLIC_SUPABASE_URL=
2 | NEXT_PUBLIC_SUPABASE_ANON_KEY=
3 | SUPABASE_SERVICE_ROLE_KEY=
4 |
5 | NEXT_PUBLIC_STRIPE_KEY=
6 | STRIPE_SECRET_KEY=
7 | STRIPE_ACCOUNT_LOGIN_LINK_REDIRECT_URL=/dashboard
8 | STRIPE_ACCOUNT_ONBOARDING_LINK_REFRESH_URL=/dashboard
9 | STRIPE_ACCOUNT_ONBOARDING_LINK_RETURN_URL=/dashboard
10 | STRIPE_ACCOUNT_WEBHOOK_SECRET=
11 | STRIPE_CUSTOMER_WEBHOOK_SECRET=
12 |
13 | API_ROUTE_SECRET=
14 |
15 | SENDGRID_API_KEY=
16 | SENDGRID_TEMPLATE_ID=
17 |
18 | NEXT_PUBLIC_URL=
19 |
20 | NEXT_PUBLIC_WITHINGS_CLIENT_ID=
21 | WITHINGS_SECRET=
22 | NEXT_PUBLIC_WITHINGS_REDIRECT_URI=/dashboard
23 | NEXT_PUBLIC_WITHINGS_TARGET_ENDPOINT=https://wbsapi.withings.net
24 | NEXT_PUBLIC_WITHINGS_AUTH_ENDPOINT=https://account.withings.com/oauth2_user/authorize2
--------------------------------------------------------------------------------
/pages/subscription/[id].jsx:
--------------------------------------------------------------------------------
1 | /* eslint-disable no-param-reassign */
2 | import Head from "next/head";
3 | import { useRouter } from "next/router";
4 | import { useState } from "react";
5 | import Subscription from "../../components/subscription/Subscription";
6 |
7 | export default function SubscriptionPage() {
8 | const router = useRouter();
9 | const { id } = router.query;
10 |
11 | const [coachEmail, setCoachEmail] = useState(null);
12 |
13 | return (
14 | <>
15 |
16 | {coachEmail ? (
17 | {coachEmail}'s Coaching Subscription - Repsetter
18 | ) : (
19 | Coaching Subscription - Repsetter
20 | )}
21 |
22 |
23 | >
24 | );
25 | }
26 |
--------------------------------------------------------------------------------
/components/GoogleDriveVideo.jsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 |
3 | function classNames(...classes) {
4 | return classes.filter(Boolean).join(" ");
5 | }
6 | const keysToDelete = ["className", "videoId", "width", "height"];
7 | const GoogleDriveVideo = React.forwardRef((props = {}, ref) => {
8 | const { className, videoId, width, height } = props;
9 |
10 | const propsSubset = Object.assign({}, props);
11 | keysToDelete.forEach((key) => delete propsSubset[key]);
12 |
13 | return (
14 |
22 | );
23 | });
24 | GoogleDriveVideo.displayName = "GoogleDriveVideo";
25 | export default GoogleDriveVideo;
26 |
--------------------------------------------------------------------------------
/components/QRCodeModal.jsx:
--------------------------------------------------------------------------------
1 | /* eslint-disable react/destructuring-assignment */
2 | import Modal from "./Modal";
3 | import { useQRCode } from "next-qrcode";
4 |
5 | export default function QRCodeModal(props) {
6 | const { text } = props;
7 | const { Image } = useQRCode();
8 |
9 | return (
10 |
14 |
15 |
27 |
28 |
29 | );
30 | }
31 |
--------------------------------------------------------------------------------
/pages/_offline.jsx:
--------------------------------------------------------------------------------
1 | import Head from "next/head";
2 |
3 | export default function Offline() {
4 | return (
5 | <>
6 |
7 | Offline - Repsetter
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 | You are Offline
16 |
17 |
18 | Check your connection and try again
19 |
20 |
21 |
22 |
23 |
24 | >
25 | );
26 | }
27 |
--------------------------------------------------------------------------------
/pages/api/account/stripe-dashboard.js:
--------------------------------------------------------------------------------
1 | /* eslint-disable consistent-return */
2 | import Stripe from "stripe";
3 | import absoluteUrl from "next-absolute-url";
4 | import { getSupabaseService, getUserProfile } from "../../../utils/supabase";
5 |
6 | export default async function handler(req, res) {
7 | const stripe = Stripe(process.env.STRIPE_SECRET_KEY);
8 | const supabase = getSupabaseService();
9 | const { user } = await supabase.auth.api.getUser(req.query.access_token);
10 | if (!user) {
11 | return res.redirect("/dashboard");
12 | }
13 |
14 | const { origin } = absoluteUrl(req);
15 |
16 | const profile = await getUserProfile(user, supabase);
17 | const link = await stripe.accounts.createLoginLink(profile.stripe_account, {
18 | redirect_url: origin + process.env.STRIPE_ACCOUNT_LOGIN_LINK_REDIRECT_URL,
19 | });
20 | if (link) {
21 | res.redirect(link.url);
22 | } else {
23 | res.redirect("/dashboard");
24 | }
25 | }
26 |
--------------------------------------------------------------------------------
/pages/api/account/stripe-customer-portal.js:
--------------------------------------------------------------------------------
1 | /* eslint-disable consistent-return */
2 | import Stripe from "stripe";
3 | import absoluteUrl from "next-absolute-url";
4 | import { getSupabaseService, getUserProfile } from "../../../utils/supabase";
5 |
6 | export default async function handler(req, res) {
7 | const stripe = Stripe(process.env.STRIPE_SECRET_KEY);
8 | const supabase = getSupabaseService();
9 | const { user } = await supabase.auth.api.getUser(req.query.access_token);
10 | if (!user) {
11 | return res.redirect("/dashboard");
12 | }
13 |
14 | const { origin } = absoluteUrl(req);
15 |
16 | const profile = await getUserProfile(user, supabase);
17 | const session = await stripe.billingPortal.sessions.create({
18 | customer: profile.stripe_customer,
19 | return_url: origin + process.env.STRIPE_CUSTOMER_PORTAL_RETURN_URL,
20 | });
21 |
22 | console.log("session", session);
23 |
24 | if (session) {
25 | res.redirect(session.url);
26 | } else {
27 | res.redirect("/dashboard");
28 | }
29 | }
30 |
--------------------------------------------------------------------------------
/pages/404.jsx:
--------------------------------------------------------------------------------
1 | import Head from "next/head";
2 |
3 | export default function Error404() {
4 | return (
5 | <>
6 |
7 | 404 - Repsetter
8 |
9 |
10 |
11 |
12 |
13 | 404
14 |
15 |
16 |
17 |
18 | Page not found
19 |
20 |
21 | Please check the URL in the address bar and try again.
22 |
23 |
24 |
25 |
26 |
27 |
28 | >
29 | );
30 | }
31 |
--------------------------------------------------------------------------------
/pages/api/account/stripe-onboarding.js:
--------------------------------------------------------------------------------
1 | /* eslint-disable consistent-return */
2 | import Stripe from "stripe";
3 | import absoluteUrl from "next-absolute-url";
4 | import { getSupabaseService, getUserProfile } from "../../../utils/supabase";
5 |
6 | export default async function handler(req, res) {
7 | const stripe = Stripe(process.env.STRIPE_SECRET_KEY);
8 | const supabase = getSupabaseService();
9 | const { user } = await supabase.auth.api.getUser(req.query.access_token);
10 | if (!user) {
11 | return res.redirect("/dashboard");
12 | }
13 |
14 | const { origin } = absoluteUrl(req);
15 |
16 | const profile = await getUserProfile(user, supabase);
17 | const link = await stripe.accountLinks.create({
18 | account: profile.stripe_account,
19 | refresh_url:
20 | origin + process.env.STRIPE_ACCOUNT_ONBOARDING_LINK_REFRESH_URL,
21 | return_url: origin + process.env.STRIPE_ACCOUNT_ONBOARDING_LINK_RETURN_URL,
22 | type: "account_onboarding",
23 | });
24 | if (link) {
25 | res.redirect(link.url);
26 | } else {
27 | res.redirect("/dashboard");
28 | }
29 | }
30 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2022 Zack
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 |
--------------------------------------------------------------------------------
/pages/terms.jsx:
--------------------------------------------------------------------------------
1 | import Head from "next/head";
2 |
3 | export default function Terms() {
4 | return (
5 | <>
6 |
7 | Terms of Use - Repsetter
8 |
9 |
10 |
11 |
12 | Terms of Use
13 |
14 |
15 |
16 |
17 |
Your Account
18 |
By signing in you agree to the following:
19 |
20 |
21 | We can delete your account at any time, deleting all user data, as
22 | well as cancelling/refunding any coaching subscriptions.
23 |
24 |
25 | Subscriptions are nonrefundable. It sucks if you subscribed to a bad
26 | coach, but all you can do is cancel your subscription.
27 |
28 |
29 |
30 | >
31 | );
32 | }
33 |
--------------------------------------------------------------------------------
/pages/api/account/setup-account.js:
--------------------------------------------------------------------------------
1 | import Stripe from "stripe";
2 | import enforceApiRouteSecret from "../../../utils/enforce-api-route-secret";
3 | import { getSupabaseService } from "../../../utils/supabase";
4 |
5 | // eslint-disable-next-line consistent-return
6 | export default async function handler(req, res) {
7 | const stripe = Stripe(process.env.STRIPE_SECRET_KEY);
8 | const supabase = getSupabaseService();
9 | console.log("received request", req.query);
10 | if (!enforceApiRouteSecret(req, res)) {
11 | return;
12 | }
13 |
14 | const customer = await stripe.customers.create({
15 | email: req.body.record.email,
16 | });
17 | const account = await stripe.accounts.create({
18 | email: req.body.record.email,
19 | type: "express",
20 | });
21 |
22 | await supabase
23 | .from("profile")
24 | .update({
25 | stripe_customer: customer.id,
26 | stripe_account: account.id,
27 | })
28 | .eq("id", req.body.record.id);
29 |
30 | res.status(200).send({
31 | message: `stripe customer ${customer.id} created and account ${account.id} created for id ${req.body.record.id}`,
32 | });
33 | }
34 |
--------------------------------------------------------------------------------
/pages/_document.jsx:
--------------------------------------------------------------------------------
1 | import Document, { Html, Head, Main, NextScript } from "next/document";
2 |
3 | class MyDocument extends Document {
4 | render() {
5 | return (
6 |
7 |
8 |
9 |
10 |
11 |
16 |
17 |
21 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 | );
34 | }
35 | }
36 |
37 | export default MyDocument;
38 |
--------------------------------------------------------------------------------
/components/OfflineBanner.jsx:
--------------------------------------------------------------------------------
1 | /* eslint-disable react-hooks/exhaustive-deps */
2 |
3 | import { XCircleIcon } from '@heroicons/react/solid';
4 | import { useRouter } from 'next/router';
5 | import { useEffect, useState } from 'react';
6 |
7 | export default function OfflineBanner() {
8 | const [isOnOfflinePage, setIsOnOfflinePage] = useState(null);
9 |
10 | const router = useRouter();
11 | const handleRouteChange = () => {
12 | setIsOnOfflinePage(
13 | router.pathname === '/_offline' &&
14 | router.asPath === window.location.pathname
15 | );
16 | };
17 |
18 | useEffect(() => {
19 | handleRouteChange();
20 | }, []);
21 | useEffect(() => {
22 | router.events.on('routeChangeComplete', handleRouteChange);
23 | return () => {
24 | router.events.off('routeChangeComplete', handleRouteChange);
25 | };
26 | }, []);
27 | return (
28 | isOnOfflinePage !== null &&
29 | !isOnOfflinePage && (
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 | You're offline
38 |
39 |
40 |
41 |
42 | )
43 | );
44 | }
45 |
--------------------------------------------------------------------------------
/components/LazyImage.jsx:
--------------------------------------------------------------------------------
1 | import React, { useEffect, useState } from "react";
2 | import LazyLoad from "vanilla-lazyload";
3 | import lazyloadConfig from "../utils/lazyload-config";
4 |
5 | function classNames(...classes) {
6 | return classes.filter(Boolean).join(" ");
7 | }
8 | const keysToDelete = ["srcset", "sizes", "className", "src"];
9 | const LazyImage = React.forwardRef((props = {}, ref) => {
10 | const { className, src, srcset, sizes } = props;
11 | const propsSubset = Object.assign({}, props);
12 | keysToDelete.forEach((key) => delete propsSubset[key]);
13 |
14 | useEffect(() => {
15 | if (!document.lazyLoadInstance) {
16 | document.lazyLoadInstance = new LazyLoad(lazyloadConfig);
17 | }
18 | document.lazyLoadInstance.update();
19 | }, []);
20 |
21 | useEffect(() => {
22 | document.lazyLoadInstance?.update();
23 | }, []);
24 |
25 | const [latestSrc, setLatestSrc] = useState(src);
26 | const [overrideSrc, setOverrideSrc] = useState();
27 | if (latestSrc && latestSrc !== src) {
28 | setLatestSrc(src);
29 | setOverrideSrc(src);
30 | }
31 |
32 | return (
33 |
42 | );
43 | });
44 | LazyImage.displayName = "LazyImage";
45 | export default LazyImage;
46 |
--------------------------------------------------------------------------------
/utils/exercise-utils.js:
--------------------------------------------------------------------------------
1 | export const muscleGroupsObject = {
2 | "upper body": [
3 | "chest",
4 | "shoulders",
5 | "traps",
6 | "front delts",
7 | "medial delts",
8 | "rear delts",
9 | "lats",
10 | "upper back",
11 | ],
12 | arms: ["biceps", "triceps", "forearms"],
13 | "lower body": ["core", "obliques", "back", "abs"],
14 | legs: ["quads", "glutes", "hamstrings", "adductors", "abductors", "calves"],
15 | };
16 | export const exerciseTypeGroups = [
17 | "pull",
18 | "push",
19 | "legs",
20 | "arms",
21 | "cardio",
22 | "core",
23 | ];
24 | export const muscleGroups = Object.keys(muscleGroupsObject);
25 | export const muscles = muscleGroups.reduce((muscles, muscleGroupName) => {
26 | return muscleGroupsObject[muscleGroupName].reduce(
27 | (muscles, muscleGroupMuscle) => {
28 | return muscles.concat({
29 | name: muscleGroupMuscle,
30 | group: muscleGroupName,
31 | index: muscles.length,
32 | });
33 | },
34 | muscles
35 | );
36 | }, []);
37 |
38 | export const exerciseFeatures = [
39 | "reps",
40 | "weight",
41 | "speed",
42 | "level",
43 | "duration",
44 | "distance",
45 | ];
46 |
47 | export const distanceUnits = ["mi", "km", "m", "ft"];
48 |
49 | const kilogramToPoundRatio = 2.2046;
50 | export const kilogramsToPounds = (kilograms) =>
51 | kilograms * kilogramToPoundRatio;
52 | export const poundsToKilograms = (pounds) => pounds / kilogramToPoundRatio;
53 |
--------------------------------------------------------------------------------
/components/dashboard/modal/DeleteAccountModal.jsx:
--------------------------------------------------------------------------------
1 | /* eslint-disable react/destructuring-assignment */
2 | import { useState } from "react";
3 | import { useUser } from "../../../context/user-context";
4 | import Modal from "../../Modal";
5 | import { ExclamationIcon } from "@heroicons/react/outline";
6 |
7 | export default function DeleteAccountModal(props) {
8 | const { deleteAccount } = useUser();
9 | const [isDeletingAccount, setIsDeletingAccount] = useState(false);
10 | return (
11 | {
21 | setIsDeletingAccount(true);
22 | await deleteAccount();
23 | }}
24 | className="inline-flex w-full justify-center rounded-md border border-transparent bg-red-600 px-4 py-2 text-base font-medium text-white shadow-sm hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-red-500 focus:ring-offset-2 sm:ml-3 sm:w-auto sm:text-sm"
25 | >
26 | {isDeletingAccount ? "Deleting Account..." : "Delete Account"}
27 |
28 | }
29 | >
30 | );
31 | }
32 |
--------------------------------------------------------------------------------
/utils/send-email.js:
--------------------------------------------------------------------------------
1 | import mail from "@sendgrid/mail";
2 |
3 | mail.setApiKey(process.env.SENDGRID_API_KEY);
4 |
5 | const adminEmail = "contact@repsetter.com";
6 | const notificationsEmail = "updates@repsetter.com";
7 |
8 | export default async function sendEmail(...messages) {
9 | console.log(
10 | messages.map((message) => ({
11 | ...message,
12 | dynamicTemplateData: {
13 | email: message.to,
14 | subject: message.subject,
15 | ...message?.dynamicTemplateData,
16 | },
17 | templateId: process.env.SENDGRID_TEMPLATE_ID,
18 | from: {
19 | email: notificationsEmail,
20 | name: "Repsetter",
21 | },
22 | replyTo: adminEmail,
23 | }))
24 | );
25 | try {
26 | await mail.send(
27 | messages.map((message) => ({
28 | templateId: process.env.SENDGRID_TEMPLATE_ID,
29 | ...message,
30 | dynamicTemplateData: {
31 | email: message.to,
32 | subject: message.subject,
33 | ...message?.dynamicTemplateData,
34 | },
35 | from: {
36 | email: notificationsEmail,
37 | name: "Repsetter",
38 | ...message?.from,
39 | },
40 | replyTo: adminEmail,
41 | }))
42 | );
43 | } catch (error) {
44 | console.error(error);
45 |
46 | if (error.response) {
47 | console.error(error.response.body);
48 | }
49 | }
50 | }
51 |
52 | export async function emailAdmin(message) {
53 | sendEmail({
54 | ...message,
55 | to: adminEmail,
56 | });
57 | }
58 |
--------------------------------------------------------------------------------
/pages/api/account/set-withings-auth-code.js:
--------------------------------------------------------------------------------
1 | /* eslint-disable consistent-return */
2 | import {
3 | getSupabaseService,
4 | getUserProfile,
5 | getUserByAccessToken,
6 | } from "../../../utils/supabase";
7 |
8 | export default async function handler(req, res) {
9 | const supabase = getSupabaseService();
10 | const sendError = (error) =>
11 | res.status(200).json({
12 | status: {
13 | type: "failed",
14 | title: "Failed to update Withings auth code",
15 | ...error,
16 | },
17 | });
18 |
19 | const { user } = await getUserByAccessToken(supabase, req);
20 | if (!user) {
21 | return sendError({ message: "you are not signed in" });
22 | }
23 |
24 | const profile = await getUserProfile(user, supabase);
25 | if (!profile) {
26 | return sendError({ message: "user profile not found" });
27 | }
28 |
29 | let authCode = req.query.code || null;
30 | const updatedProfile = {};
31 | if (authCode == "null") {
32 | updatedProfile.withings_auth_code = null;
33 | updatedProfile.withings_access_token = null;
34 | updatedProfile.withings_refresh_token = null;
35 | updatedProfile.withings_token_expiration = null;
36 | updatedProfile.withings_userid = null;
37 | } else {
38 | updatedProfile.withings_auth_code = authCode;
39 | }
40 | await supabase.from("profile").update(updatedProfile).eq("id", profile.id);
41 |
42 | res.status(200).json({
43 | status: {
44 | type: "succeeded",
45 | title: "successfully updated Withings auth code",
46 | },
47 | withings_auth_code: authCode,
48 | });
49 | }
50 |
--------------------------------------------------------------------------------
/public/manifest-ios.json:
--------------------------------------------------------------------------------
1 | {
2 | "theme_color": "#f7f7f7",
3 | "background_color": "#f7f7f7",
4 | "display": "browser",
5 | "scope": "/",
6 | "start_url": "/dashboard/diary",
7 | "name": "Repsetter",
8 | "short_name": "Repsetter",
9 | "lang": "en-US",
10 | "orientation": "portrait",
11 | "description": "",
12 | "categories": [],
13 | "icons": [
14 | {
15 | "src": "/images/icon-192x192.png",
16 | "sizes": "192x192",
17 | "type": "image/png",
18 | "purpose": "any"
19 | },
20 | {
21 | "src": "/images/icon-256x256.png",
22 | "sizes": "256x256",
23 | "type": "image/png",
24 | "purpose": "any"
25 | },
26 | {
27 | "src": "/images/icon-384x384.png",
28 | "sizes": "384x384",
29 | "type": "image/png",
30 | "purpose": "any"
31 | },
32 | {
33 | "src": "/images/icon-512x512.png",
34 | "sizes": "512x512",
35 | "type": "image/png",
36 | "purpose": "any"
37 | },
38 |
39 | {
40 | "src": "/images/icon-192x192.png",
41 | "sizes": "192x192",
42 | "type": "image/png",
43 | "purpose": "maskable"
44 | },
45 | {
46 | "src": "/images/icon-256x256.png",
47 | "sizes": "256x256",
48 | "type": "image/png",
49 | "purpose": "maskable"
50 | },
51 | {
52 | "src": "/images/icon-384x384.png",
53 | "sizes": "384x384",
54 | "type": "image/png",
55 | "purpose": "maskable"
56 | },
57 | {
58 | "src": "/images/icon-512x512.png",
59 | "sizes": "512x512",
60 | "type": "image/png",
61 | "purpose": "maskable"
62 | }
63 | ],
64 |
65 | "shortcuts": []
66 | }
67 |
--------------------------------------------------------------------------------
/public/manifest.json:
--------------------------------------------------------------------------------
1 | {
2 | "theme_color": "#f7f7f7",
3 | "background_color": "#f7f7f7",
4 | "display": "standalone",
5 | "scope": "/",
6 | "start_url": "/dashboard/diary",
7 | "name": "Repsetter",
8 | "short_name": "Repsetter",
9 | "lang": "en-US",
10 | "orientation": "portrait",
11 | "description": "",
12 | "categories": [],
13 | "icons": [
14 | {
15 | "src": "/images/icon-192x192.png",
16 | "sizes": "192x192",
17 | "type": "image/png",
18 | "purpose": "any"
19 | },
20 | {
21 | "src": "/images/icon-256x256.png",
22 | "sizes": "256x256",
23 | "type": "image/png",
24 | "purpose": "any"
25 | },
26 | {
27 | "src": "/images/icon-384x384.png",
28 | "sizes": "384x384",
29 | "type": "image/png",
30 | "purpose": "any"
31 | },
32 | {
33 | "src": "/images/icon-512x512.png",
34 | "sizes": "512x512",
35 | "type": "image/png",
36 | "purpose": "any"
37 | },
38 |
39 | {
40 | "src": "/images/icon-192x192.png",
41 | "sizes": "192x192",
42 | "type": "image/png",
43 | "purpose": "maskable"
44 | },
45 | {
46 | "src": "/images/icon-256x256.png",
47 | "sizes": "256x256",
48 | "type": "image/png",
49 | "purpose": "maskable"
50 | },
51 | {
52 | "src": "/images/icon-384x384.png",
53 | "sizes": "384x384",
54 | "type": "image/png",
55 | "purpose": "maskable"
56 | },
57 | {
58 | "src": "/images/icon-512x512.png",
59 | "sizes": "512x512",
60 | "type": "image/png",
61 | "purpose": "maskable"
62 | }
63 | ],
64 |
65 | "shortcuts": []
66 | }
67 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Repsetter
2 |
3 | This is a [Next.js](https://nextjs.org/) project bootstrapped with [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app).
4 |
5 | ## Getting Started
6 |
7 | First, run the development server:
8 |
9 | ```bash
10 | yarn dev
11 | ```
12 |
13 | Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
14 |
15 | You can start editing the page by modifying `pages/index.js`. The page auto-updates as you edit the file.
16 |
17 | [API routes](https://nextjs.org/docs/api-routes/introduction) can be accessed on [http://localhost:3000/api/hello](http://localhost:3000/api/hello). This endpoint can be edited in `pages/api/hello.js`.
18 |
19 | The `pages/api` directory is mapped to `/api/*`. Files in this directory are treated as [API routes](https://nextjs.org/docs/api-routes/introduction) instead of React pages.
20 |
21 | ## Learn More
22 |
23 | To learn more about Next.js, take a look at the following resources:
24 |
25 | - [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
26 | - [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
27 |
28 | You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js/) - your feedback and contributions are welcome!
29 |
30 | ## Deploy on Vercel
31 |
32 | The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
33 |
34 | Check out our [Next.js deployment documentation](https://nextjs.org/docs/deployment) for more details.
35 |
--------------------------------------------------------------------------------
/components/LazyVideo.jsx:
--------------------------------------------------------------------------------
1 | import React, { useEffect, useState } from "react";
2 | import LazyLoad from "vanilla-lazyload";
3 | import lazyloadConfig from "../utils/lazyload-config";
4 |
5 | function classNames(...classes) {
6 | return classes.filter(Boolean).join(" ");
7 | }
8 | const keysToDelete = [
9 | "srcset",
10 | "sizes",
11 | "className",
12 | "src",
13 | "onPlay",
14 | "onPause",
15 | "poster",
16 | ];
17 | const LazyVideo = React.forwardRef((props = {}, ref) => {
18 | const { className, src, srcset, sizes, onPlay, onPause, poster } = props;
19 | const propsSubset = Object.assign({}, props);
20 | keysToDelete.forEach((key) => delete propsSubset[key]);
21 | useEffect(() => {
22 | if (!document.lazyLoadInstance) {
23 | document.lazyLoadInstance = new LazyLoad(lazyloadConfig);
24 | }
25 | document.lazyLoadInstance.update();
26 | }, []);
27 |
28 | useEffect(() => {
29 | document.lazyLoadInstance?.update();
30 | }, []);
31 |
32 | const [hasPlayed, setHasPlayed] = useState(false);
33 |
34 | useEffect(() => {
35 | if (src && hasPlayed && ref.current) {
36 | const video = ref.current;
37 | video.src = src;
38 | setHasPlayed(false);
39 | onPause();
40 | }
41 | }, [src]);
42 |
43 | return (
44 | {
46 | setHasPlayed(true);
47 | onPlay();
48 | }}
49 | poster={poster}
50 | {...propsSubset}
51 | ref={ref}
52 | className={classNames(className || "", "lazy")}
53 | data-src={src}
54 | data-srcset={srcset}
55 | data-sizes={sizes}
56 | data-poster={poster}
57 | />
58 | );
59 | });
60 | LazyVideo.displayName = "LazyVideo";
61 | export default LazyVideo;
62 |
--------------------------------------------------------------------------------
/pages/api/account/refresh-withings-access-token.js:
--------------------------------------------------------------------------------
1 | /* eslint-disable consistent-return */
2 | import {
3 | getSupabaseService,
4 | getUserProfile,
5 | getUserByAccessToken,
6 | } from "../../../utils/supabase";
7 | import { refreshWithingsAccessToken } from "../../../utils/withings";
8 |
9 | export default async function handler(req, res) {
10 | const supabase = getSupabaseService();
11 | const sendError = (error) =>
12 | res.status(200).json({
13 | status: {
14 | type: "failed",
15 | title: "Failed to refresh Withings access token",
16 | ...error,
17 | },
18 | });
19 |
20 | const { user } = await getUserByAccessToken(supabase, req);
21 | if (!user) {
22 | return sendError({ message: "you are not signed in" });
23 | }
24 |
25 | const profile = await getUserProfile(user, supabase);
26 | if (!profile) {
27 | return sendError({ message: "user profile not found" });
28 | }
29 | if (!profile.withings_auth_code) {
30 | return sendError({ message: "no withings authorization code found" });
31 | }
32 |
33 | const json = await refreshWithingsAccessToken(profile.withings_refresh_token);
34 | if (json.status != 0) {
35 | return sendError({ message: json.error });
36 | }
37 | const { access_token, refresh_token, expires_in } = json.body;
38 | await supabase
39 | .from("profile")
40 | .update({
41 | withings_access_token: access_token,
42 | withings_refresh_token: refresh_token,
43 | withings_token_expiration: expires_in,
44 | })
45 | .eq("id", profile.id);
46 |
47 | res.status(200).json({
48 | status: {
49 | type: "succeeded",
50 | title: "successfully refreshed Withings access token",
51 | },
52 | data: json.body,
53 | });
54 | }
55 |
--------------------------------------------------------------------------------
/utils/subscription-utils.js:
--------------------------------------------------------------------------------
1 | export const maxNumberOfUnredeemedSubscriptionsPerCoach = 5;
2 |
3 | export function truncateDollars(value, roundUp = true) {
4 | value = Number(value);
5 | value *= 100;
6 | value = roundUp ? Math.ceil(value) : Math.floor(value);
7 | value /= 100;
8 | return value;
9 | }
10 |
11 | export function dollarsToCents(dollars) {
12 | return Math.round(dollars * 100);
13 | }
14 |
15 | export const repsetterFeePercentage = 5;
16 |
17 | const defaultLocale = "en-us";
18 |
19 | export function formatDollars(dollars, useDecimals = true) {
20 | return dollars.toLocaleString(defaultLocale, {
21 | minimumFractionDigits: useDecimals ? 2 : 0,
22 | maximumFractionDigits: useDecimals ? 2 : 0,
23 | style: "currency",
24 | currency: "USD",
25 | });
26 | }
27 |
28 | export async function updateNumberOfUnredeemedSubscriptions(
29 | coachProfile,
30 | supabase
31 | ) {
32 | const { count: new_number_of_unredeemed_subscriptions } = await supabase
33 | .from("subscription")
34 | .select("*", { count: "exact", head: true })
35 | .match({ coach: coachProfile.id, redeemed: false });
36 | console.log(
37 | "new_number_of_unredeemed_subscriptions",
38 | new_number_of_unredeemed_subscriptions
39 | );
40 |
41 | const updateProfileResult = await supabase
42 | .from("profile")
43 | .update({
44 | number_of_unredeemed_subscriptions:
45 | new_number_of_unredeemed_subscriptions,
46 | })
47 | .eq("id", coachProfile.id);
48 | console.log(updateProfileResult);
49 | }
50 |
51 | export async function deleteClientSubscriptions(client, supabase) {}
52 | export async function deleteCoachingSubscriptions(coach, supabase) {}
53 | export async function deleteSubscription(subscriptionId, supabase) {}
54 |
--------------------------------------------------------------------------------
/pages/api/account/get-withings-access-token.js:
--------------------------------------------------------------------------------
1 | /* eslint-disable consistent-return */
2 | import {
3 | getSupabaseService,
4 | getUserProfile,
5 | getUserByAccessToken,
6 | } from "../../../utils/supabase";
7 | import { getWithingsAccessToken } from "../../../utils/withings";
8 |
9 | export default async function handler(req, res) {
10 | const supabase = getSupabaseService();
11 | const sendError = (error) =>
12 | res.status(200).json({
13 | status: {
14 | type: "failed",
15 | title: "Failed to get Withings access token",
16 | ...error,
17 | },
18 | });
19 |
20 | const { user } = await getUserByAccessToken(supabase, req);
21 | if (!user) {
22 | return sendError({ message: "you are not signed in" });
23 | }
24 |
25 | const profile = await getUserProfile(user, supabase);
26 | if (!profile) {
27 | return sendError({ message: "user profile not found" });
28 | }
29 | if (!profile.withings_auth_code) {
30 | return sendError({ message: "no withings authorization code found" });
31 | }
32 |
33 | const json = await getWithingsAccessToken(profile.withings_auth_code);
34 | if (json.status != 0) {
35 | return sendError({ message: json.error });
36 | }
37 | const { access_token, refresh_token, expires_in, userid } = json.body;
38 | console.log(access_token, refresh_token, expires_in, userid);
39 | await supabase
40 | .from("profile")
41 | .update({
42 | withings_access_token: access_token,
43 | withings_refresh_token: refresh_token,
44 | withings_token_expiration: expires_in,
45 | withings_userid: userid,
46 | })
47 | .eq("id", profile.id);
48 |
49 | res.status(200).json({
50 | status: {
51 | type: "succeeded",
52 | title: "Successfully Received Withings access token",
53 | },
54 | data: json.body,
55 | });
56 | }
57 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "repsetter",
3 | "version": "0.1.0",
4 | "private": true,
5 | "browser": {
6 | "child_process": false
7 | },
8 | "scripts": {
9 | "dev": "next dev",
10 | "build": "next build",
11 | "start": "next start",
12 | "lint": "next lint"
13 | },
14 | "dependencies": {
15 | "@headlessui/react": "^1.6.0",
16 | "@heroicons/react": "^1.0.6",
17 | "@sendgrid/mail": "^7.7.0",
18 | "@stripe/react-stripe-js": "^1.7.2",
19 | "@stripe/stripe-js": "^1.29.0",
20 | "@supabase/gotrue-js": "^1.22.16",
21 | "@supabase/supabase-js": "^1.35.2",
22 | "@tailwindcss/aspect-ratio": "^0.4.0",
23 | "@tailwindcss/forms": "^0.5.0",
24 | "@tailwindcss/typography": "^0.5.2",
25 | "chart.js": "^3.8.0",
26 | "chartjs-adapter-date-fns": "^2.0.0",
27 | "cookie": "^0.5.0",
28 | "date-fns": "^2.28.0",
29 | "eslint-config-airbnb": "^19.0.4",
30 | "eslint-plugin-react": "^7.28.0",
31 | "image-conversion": "^2.1.1",
32 | "lodash": "^4.17.21",
33 | "micro": "^9.3.4",
34 | "next": "^12.1.5",
35 | "next-absolute-url": "^1.2.2",
36 | "next-pwa": "^5.5.2",
37 | "next-qrcode": "^2.0.0",
38 | "prettier-plugin-tailwindcss": "^0.1.10",
39 | "react": "18.1.0",
40 | "react-chartjs-2": "^4.2.0",
41 | "react-device-detect": "^2.2.2",
42 | "react-dom": "18.1.0",
43 | "react-youtube": "^9.0.2",
44 | "sharp": "^0.30.4",
45 | "stripe": "^8.220.0",
46 | "swr": "^1.3.0",
47 | "vanilla-lazyload": "^17.8.2"
48 | },
49 | "devDependencies": {
50 | "autoprefixer": "^10.4.5",
51 | "eslint": "^7.32.0",
52 | "eslint-config-next": "12.1.5",
53 | "eslint-config-prettier": "^8.5.0",
54 | "eslint-plugin-prettier": "^4.0.0",
55 | "postcss": "^8.4.5",
56 | "prettier": "^2.6.2",
57 | "tailwindcss": "^3.0.24"
58 | }
59 | }
60 |
--------------------------------------------------------------------------------
/utils/wii-balance-board/const.js:
--------------------------------------------------------------------------------
1 | // originally created by PicchiKevin
2 | // https://github.com/PicchiKevin/wiimote-webhid
3 |
4 | export const ReportMode = {
5 | RUMBLE: 0x10,
6 | PLAYER_LED: 0x11,
7 | DATA_REPORTING: 0x12,
8 | IR_CAMERA_ENABLE: 0x13,
9 | SPEAKER_ENABLE: 0x14,
10 | STATUS_INFO_REQ: 0x15,
11 | MEM_REG_WRITE: 0x16,
12 | MEM_REG_READ: 0x17,
13 | SPEAKER_DATA: 0x18,
14 | SPEAKER_MUTE: 0x19,
15 | IR_CAMERA2_ENABLE: 0x1a,
16 | };
17 |
18 | export const DataReportMode = {
19 | CORE_BUTTONS: 0x30,
20 | CORE_BUTTONS_AND_ACCEL: 0x31,
21 | EXTENSION_8BYTES: 0x32,
22 | CORE_BUTTONS_ACCEL_IR: 0x33,
23 | };
24 |
25 | export const InputReport = {
26 | STATUS: 0x20,
27 | READ_MEM_DATA: 0x21,
28 | ACK: 0x22,
29 | };
30 |
31 | export const Rumble = {
32 | ON: 0x01,
33 | OFF: 0x00,
34 | };
35 |
36 | export const LEDS = {
37 | ONE: 0x10,
38 | TWO: 0x20,
39 | THREE: 0x40,
40 | FOUR: 0x80,
41 | };
42 |
43 | export const BUTTON_BYTE1 = [
44 | "DPAD_LEFT",
45 | "DPAD_RIGHT",
46 | "DPAD_DOWN",
47 | "DPAD_UP",
48 | "PLUS",
49 | "",
50 | "",
51 | "",
52 | ];
53 |
54 | export const BUTTON_BYTE2 = ["TWO", "ONE", "B", "A", "MINUS", "", "", "HOME"];
55 |
56 | export const IRDataType = {
57 | BASIC: 0x1,
58 | EXTENDED: 0x3,
59 | FULL: 0x5,
60 | };
61 |
62 | export const RegisterType = {
63 | EEPROM: 0x00,
64 | CONTROL: 0x04,
65 | };
66 |
67 | export const IRSensitivity = {
68 | LEVEL_1: [0x02, 0x00, 0x00, 0x71, 0x01, 0x00, 0x64, 0x00, 0xfe],
69 | LEVEL_2: [0x02, 0x00, 0x00, 0x71, 0x01, 0x00, 0x96, 0x00, 0xb4],
70 | LEVEL_3: [0x02, 0x00, 0x00, 0x71, 0x01, 0x00, 0xaa, 0x00, 0x64],
71 | LEVEL_4: [0x02, 0x00, 0x00, 0x71, 0x01, 0x00, 0xc8, 0x00, 0x36],
72 | LEVEL_5: [0x07, 0x00, 0x00, 0x71, 0x01, 0x00, 0x72, 0x00, 0x20],
73 | BLOCK_1: [0xfd, 0x05],
74 | BLOCK_2: [0xb3, 0x04],
75 | BLOCK_3: [0x63, 0x03],
76 | BLOCK_4: [0x35, 0x03],
77 | BLOCK_5: [0x1f, 0x03],
78 | };
79 |
80 | export const WiiBalanceBoardPositions = {
81 | TOP_RIGHT: 0,
82 | BOTTOM_RIGHT: 1,
83 | TOP_LEFT: 2,
84 | BOTTOM_LEFT: 3,
85 | };
86 |
--------------------------------------------------------------------------------
/components/dashboard/modal/DeleteUserModal.jsx:
--------------------------------------------------------------------------------
1 | /* eslint-disable react/destructuring-assignment */
2 | import { useState, useEffect } from "react";
3 | import { useUser } from "../../../context/user-context";
4 | import Modal from "../../Modal";
5 |
6 | export default function DeleteUserModal(props) {
7 | const {
8 | open,
9 | setOpen,
10 | selectedResult: selectedUser,
11 | setDeleteResultStatus: setDeleteUserStatus,
12 | setShowDeleteResultNotification: setShowDeleteUserNotification,
13 | } = props;
14 | const [isDeleting, setIsDeleting] = useState(false);
15 | const [didDelete, setDidDelete] = useState(false);
16 | const { fetchWithAccessToken } = useUser();
17 |
18 | useEffect(() => {
19 | if (open) {
20 | setIsDeleting(false);
21 | setDidDelete(false);
22 | }
23 | }, [open]);
24 |
25 | return (
26 | {
35 | setIsDeleting(true);
36 | const response = await fetchWithAccessToken(
37 | `/api/account/delete-account?userId=${selectedUser.id}`
38 | );
39 | setIsDeleting(false);
40 | setDidDelete(true);
41 | const { status } = await response.json();
42 | setDeleteUserStatus(status);
43 | setShowDeleteUserNotification(true);
44 | setOpen(false);
45 | }}
46 | className="inline-flex w-full justify-center rounded-md border border-transparent bg-red-600 px-4 py-2 text-base font-medium text-white shadow-sm hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-red-500 focus:ring-offset-2 sm:ml-3 sm:w-auto sm:text-sm"
47 | >
48 | {/* eslint-disable-next-line no-nested-ternary */}
49 | {isDeleting
50 | ? "Deleting User..."
51 | : didDelete
52 | ? "Deleted User!"
53 | : "Delete User"}
54 |
55 | }
56 | >
57 | );
58 | }
59 |
--------------------------------------------------------------------------------
/components/dashboard/modal/DeleteSubscriptionModal.jsx:
--------------------------------------------------------------------------------
1 | /* eslint-disable react/destructuring-assignment */
2 | import { useState, useEffect } from "react";
3 | import { useUser } from "../../../context/user-context";
4 | import Modal from "../../Modal";
5 |
6 | export default function DeleteSubscriptionModal(props) {
7 | const {
8 | open,
9 | setOpen,
10 | selectedResult: selectedSubscription,
11 | setDeleteResultStatus: setDeleteSubscriptionStatus,
12 | setShowDeleteResultNotification: setShowDeleteSubscriptionNotification,
13 | } = props;
14 | const [isDeleting, setIsDeleting] = useState(false);
15 | const [didDelete, setDidDelete] = useState(false);
16 | const { fetchWithAccessToken } = useUser();
17 |
18 | useEffect(() => {
19 | if (open) {
20 | setIsDeleting(false);
21 | setDidDelete(false);
22 | }
23 | }, [open]);
24 |
25 | return (
26 | {
35 | setIsDeleting(true);
36 | const response = await fetchWithAccessToken(
37 | `/api/subscription/delete-subscription?subscriptionId=${selectedSubscription.id}`
38 | );
39 | setIsDeleting(false);
40 | setDidDelete(true);
41 | const { status } = await response.json();
42 | setDeleteSubscriptionStatus(status);
43 | setShowDeleteSubscriptionNotification(true);
44 | setOpen(false);
45 | }}
46 | className="inline-flex w-full justify-center rounded-md border border-transparent bg-red-600 px-4 py-2 text-base font-medium text-white shadow-sm hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-red-500 focus:ring-offset-2 sm:ml-3 sm:w-auto sm:text-sm"
47 | >
48 | {/* eslint-disable-next-line no-nested-ternary */}
49 | {isDeleting
50 | ? "Deleting Subscription..."
51 | : didDelete
52 | ? "Deleted Subscription!"
53 | : "Delete Subscription"}
54 |
55 | }
56 | >
57 | );
58 | }
59 |
--------------------------------------------------------------------------------
/context/exercise-types-context.jsx:
--------------------------------------------------------------------------------
1 | import { useEffect, useState, createContext, useContext } from "react";
2 | import { supabase } from "../utils/supabase";
3 |
4 | export const ExerciseTypesContext = createContext();
5 |
6 | export function ExerciseTypesContextProvider(props) {
7 | const [exerciseTypes, setExerciseTypes] = useState();
8 | const [isGettingExerciseTypes, setIsGettingExerciseTypes] = useState(false);
9 | const getExerciseTypes = async (refresh) => {
10 | if (!exerciseTypes || refresh) {
11 | setIsGettingExerciseTypes(true);
12 | const { data: exerciseTypes } = await supabase
13 | .from("exercise_type")
14 | .select("*")
15 | .order("name", { ascending: true });
16 | console.log("fetched exerciseTypes", exerciseTypes);
17 | setExerciseTypes(exerciseTypes);
18 | setIsGettingExerciseTypes(false);
19 | }
20 | };
21 |
22 | /*
23 | useEffect(() => {
24 | if (exerciseTypes) {
25 | console.log(`subscribing to exercise_type updates`);
26 | const subscription = supabase
27 | .from(`exercise_type`)
28 | .on("INSERT", (payload) => {
29 | console.log(`new exercise`, payload);
30 | getExerciseTypes(true);
31 | })
32 | .on("UPDATE", (payload) => {
33 | console.log(`updated exercise`, payload);
34 | getExerciseTypes(true);
35 | })
36 | .on("DELETE", (payload) => {
37 | console.log(`deleted exercise`, payload);
38 | const deletedExerciseType = payload.old;
39 | // eslint-disable-next-line no-shadow
40 | setExerciseTypes(
41 | exerciseTypes.filter(
42 | (exerciseType) => exerciseType?.id !== deletedExerciseType.id
43 | )
44 | );
45 | })
46 | .subscribe();
47 | return () => {
48 | console.log(`unsubscribing to exercise_type updates`);
49 | supabase.removeSubscription(subscription);
50 | };
51 | }
52 | }, [exerciseTypes]);
53 | */
54 |
55 | const value = {
56 | getExerciseTypes,
57 | isGettingExerciseTypes,
58 | exerciseTypes,
59 | };
60 |
61 | return ;
62 | }
63 |
64 | export function useExerciseTypes() {
65 | const context = useContext(ExerciseTypesContext);
66 | return context;
67 | }
68 |
--------------------------------------------------------------------------------
/pages/api/account/set-notifications.js:
--------------------------------------------------------------------------------
1 | /* eslint-disable consistent-return */
2 | import {
3 | getSupabaseService,
4 | getUserProfile,
5 | getUserByAccessToken,
6 | } from "../../../utils/supabase";
7 |
8 | export const notificationTypes = [
9 | {
10 | value: "email_subscription_created_client",
11 | title: "New Coach",
12 | description: "Be notified when you get a new coach",
13 | },
14 | {
15 | value: "email_subscription_cancelled_client",
16 | title: "Cancelled Subscription",
17 | description: "Be notified when your subscription is cancelled",
18 | },
19 | {
20 | value: "email_subscription_ended_client",
21 | title: "Subscription Ends",
22 | description: "Be notified when your subscription ends",
23 | },
24 |
25 | {
26 | value: "email_subscription_created_coach",
27 | title: "New Client",
28 | description: "Be notified when you get a new client",
29 | isCoach: true,
30 | },
31 | {
32 | value: "email_subscription_cancelled_coach",
33 | title: "Client Cancelled",
34 | description: "Be notified when a client has cancelled their subscription",
35 | isCoach: true,
36 | },
37 | {
38 | value: "email_subscription_ended_coach",
39 | title: "Client's Subscription Ends",
40 | description: "Be notified when a client's subscription ends",
41 | isCoach: true,
42 | },
43 | ];
44 |
45 | export default async function handler(req, res) {
46 | const sendError = (error) =>
47 | res.status(200).json({
48 | status: {
49 | type: "failed",
50 | title: "Failed to Update Notifications",
51 | ...error,
52 | },
53 | });
54 |
55 | const supabase = getSupabaseService();
56 | const { user } = await getUserByAccessToken(supabase, req);
57 | if (!user) {
58 | return sendError({ message: "You are not signed in" });
59 | }
60 |
61 | const profile = await getUserProfile(user, supabase);
62 | if (!profile) {
63 | return sendError({ message: "profile not found" });
64 | }
65 |
66 | const notifications = notificationTypes
67 | .filter((notificationType) => req.body[notificationType.value] === "on")
68 | .map(({ value }) => value);
69 | await supabase.from("profile").update({ notifications }).eq("id", profile.id);
70 |
71 | res.status(200).json({
72 | status: {
73 | type: "succeeded",
74 | title: "Successfully Updated Notifications",
75 | },
76 | });
77 | }
78 |
--------------------------------------------------------------------------------
/utils/EventDispatcher.js:
--------------------------------------------------------------------------------
1 | /**
2 | * https://github.com/mrdoob/eventdispatcher.js/
3 | */
4 |
5 | class EventDispatcher {
6 | addEventListener(type, listener) {
7 | if (this._listeners === undefined) this._listeners = {};
8 |
9 | const listeners = this._listeners;
10 |
11 | if (listeners[type] === undefined) {
12 | listeners[type] = [];
13 | }
14 |
15 | if (listeners[type].indexOf(listener) === -1) {
16 | listeners[type].push(listener);
17 | }
18 | }
19 |
20 | hasEventListener(type, listener) {
21 | if (this._listeners === undefined) return false;
22 |
23 | const listeners = this._listeners;
24 |
25 | return (
26 | listeners[type] !== undefined && listeners[type].indexOf(listener) !== -1
27 | );
28 | }
29 |
30 | removeEventListener(type, listener) {
31 | if (this._listeners === undefined) return;
32 |
33 | const listeners = this._listeners;
34 | const listenerArray = listeners[type];
35 |
36 | if (listenerArray !== undefined) {
37 | const index = listenerArray.indexOf(listener);
38 |
39 | if (index !== -1) {
40 | listenerArray.splice(index, 1);
41 | }
42 | }
43 | }
44 |
45 | dispatchEvent(event) {
46 | if (this._listeners === undefined) return;
47 |
48 | const listeners = this._listeners;
49 | const listenerArray = listeners[event.type];
50 |
51 | if (listenerArray !== undefined) {
52 | event.target = this;
53 |
54 | // Make a copy, in case listeners are removed while iterating.
55 | const array = listenerArray.slice(0);
56 |
57 | for (let i = 0, l = array.length; i < l; i++) {
58 | array[i].call(this, event);
59 | }
60 |
61 | event.target = null;
62 | }
63 | }
64 | }
65 |
66 | {
67 | const eventDispatcherAddEventListener =
68 | EventDispatcher.prototype.addEventListener;
69 | EventDispatcher.prototype.addEventListener = function (
70 | type,
71 | listener,
72 | options
73 | ) {
74 | if (options) {
75 | if (options.once) {
76 | function onceCallback(event) {
77 | listener.apply(this, arguments);
78 | this.removeEventListener(type, onceCallback);
79 | }
80 | eventDispatcherAddEventListener.call(this, type, onceCallback);
81 | }
82 | } else {
83 | eventDispatcherAddEventListener.apply(this, arguments);
84 | }
85 | };
86 | }
87 |
88 | export { EventDispatcher };
89 |
--------------------------------------------------------------------------------
/context/coach-picture-context.jsx:
--------------------------------------------------------------------------------
1 | import { useState, createContext, useContext } from "react";
2 | import { supabase, generateUrlSuffix } from "../utils/supabase";
3 |
4 | export const CoachPicturesContext = createContext();
5 |
6 | export function CoachPicturesContextProvider(props) {
7 | const [coachPictures, setCoachPictures] = useState({});
8 |
9 | const getCoachPicture = async (ids, refresh = false) => {
10 | console.log("requesting coach picture", ids);
11 |
12 | ids = Array.isArray(ids) ? ids : [ids];
13 |
14 | const newCoachPictures = { ...coachPictures };
15 | let updateCoachPictures = false;
16 |
17 | await Promise.all(
18 | ids.map(async (id) => {
19 | if (!coachPictures[id] || refresh) {
20 | const { data: list, error: listError } = await supabase.storage
21 | .from("coach-picture")
22 | .list(id);
23 | if (listError) {
24 | console.error(listError);
25 | }
26 |
27 | console.log("coachesList", list);
28 |
29 | const imageDetails = list?.find(({ name }) =>
30 | name.startsWith("image")
31 | );
32 |
33 | if (imageDetails) {
34 | const { publicURL: coachPictureUrl, error: getCoachPictureError } =
35 | await supabase.storage
36 | .from("coach-picture")
37 | .getPublicUrl(
38 | `${id}/image.jpg?${generateUrlSuffix(imageDetails)}`
39 | );
40 |
41 | if (getCoachPictureError) {
42 | console.error(getCoachPictureError);
43 | } else {
44 | newCoachPictures[id] = {
45 | url: coachPictureUrl,
46 | };
47 | updateCoachPictures = true;
48 | }
49 | } else {
50 | console.log("existing", coachPictures);
51 | newCoachPictures[id] = {};
52 | updateCoachPictures = true;
53 | console.log("newCoachPictures", newCoachPictures);
54 | }
55 | } else {
56 | console.log("coach picture cache hit");
57 | }
58 | })
59 | );
60 |
61 | if (updateCoachPictures) {
62 | setCoachPictures(newCoachPictures);
63 | }
64 | };
65 |
66 | const value = { coachPictures, getCoachPicture };
67 |
68 | return ;
69 | }
70 |
71 | export function useCoachPictures() {
72 | const context = useContext(CoachPicturesContext);
73 | return context;
74 | }
75 |
--------------------------------------------------------------------------------
/pages/_app.jsx:
--------------------------------------------------------------------------------
1 | import "../styles/index.css";
2 | import { UserContextProvider } from "../context/user-context";
3 | import { OnlineContextProvider } from "../context/online-context";
4 | import { ClientContextProvider } from "../context/client-context";
5 | import { ExerciseVideoContextProvider } from "../context/exercise-videos-context";
6 | import { SelectedExerciseTypeContextProvider } from "../context/selected-exercise-context";
7 | import { ProgressContextProvider } from "../context/progress-context";
8 | import { CoachPicturesContextProvider } from "../context/coach-picture-context";
9 | import { PicturesContextProvider } from "../context/picture-context";
10 | import { ExerciseTypesContextProvider } from "../context/exercise-types-context";
11 | import { WiiBalanceBoardContextProvider } from "../context/wii-balance-board-context";
12 | import Layout from "../components/layouts/Layout";
13 | import { useEffect } from "react";
14 | import { isIOS } from "react-device-detect";
15 |
16 | function MyApp({ Component, pageProps }) {
17 | const getLayout = Component.getLayout || ((page) => page);
18 |
19 | const updateManifest = async () => {
20 | const manifestElement = document.querySelector("link[rel='manifest']");
21 | if (manifestElement) {
22 | manifestElement.href = isIOS ? "/manifest-ios.json" : "/manifest.json";
23 | }
24 | };
25 | useEffect(() => {
26 | updateManifest();
27 | }, [isIOS]);
28 | return (
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 | {getLayout( )}
41 |
42 |
43 |
44 |
45 |
46 |
47 |
48 |
49 |
50 |
51 |
52 | );
53 | }
54 |
55 | export default MyApp;
56 |
--------------------------------------------------------------------------------
/utils/supabase.js:
--------------------------------------------------------------------------------
1 | import { createClient } from "@supabase/supabase-js";
2 | import cookie from "cookie";
3 |
4 | export const paginationSize = 1_000;
5 | export const storagePaginationSize = 100;
6 |
7 | export const supabase = createClient(
8 | process.env.NEXT_PUBLIC_SUPABASE_URL,
9 | process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY
10 | );
11 |
12 | export const supabaseAuthHeader = "x-supabase-auth";
13 | export const getUserByAccessToken = async (supabase, req) => {
14 | const accessToken = req.headers[supabaseAuthHeader];
15 | return supabase.auth.api.getUser(accessToken);
16 | };
17 |
18 | export const isUserAdmin = (user) => user.email?.endsWith("@ukaton.com");
19 |
20 | export const getSupabaseService = (req) => {
21 | const supabaseService = createClient(
22 | process.env.NEXT_PUBLIC_SUPABASE_URL,
23 | process.env.SUPABASE_SERVICE_ROLE_KEY
24 | );
25 | if (req) {
26 | const token = cookie.parse(req.headers.cookie)["sb-access-token"];
27 | supabaseService.auth.session = () => ({
28 | access_token: token,
29 | });
30 | }
31 | return supabaseService;
32 | };
33 |
34 | export async function getUserProfile(user, _supabase = supabase) {
35 | const { data: profile } = await _supabase
36 | .from("profile")
37 | .select("*")
38 | .eq("id", user.id)
39 | .single();
40 | return profile;
41 | }
42 |
43 | export const dateFromDateAndTime = (date, time) => {
44 | const fullDate = new Date();
45 |
46 | const [year, month, day] = date.split("-");
47 | fullDate.setFullYear(year);
48 | fullDate.setMonth(month - 1);
49 | fullDate.setDate(day);
50 |
51 | if (time) {
52 | const [hours, minutes] = time.split(":");
53 | fullDate.setHours(hours);
54 | fullDate.setMinutes(minutes);
55 | } else {
56 | fullDate.setHours(0);
57 | fullDate.setMinutes(0);
58 | }
59 |
60 | return fullDate;
61 | };
62 |
63 | export const generateUrlSuffix = (object) => {
64 | return object ? `t=${new Date(object.updated_at).getTime()}` : "";
65 | };
66 |
67 | export const timeToDate = (time) => {
68 | const [hours, minutes, seconds] = time.split(":");
69 | const date = new Date();
70 | date.setHours(hours, minutes, seconds);
71 | return date;
72 | };
73 |
74 | export const dateToString = (date) => {
75 | return `${date.getFullYear()}-${(date.getMonth() + 1)
76 | .toString()
77 | .padStart(2, "0")}-${date.getDate().toString().padStart(2, "0")}`;
78 | };
79 |
80 | export const stringToDate = (string) => {
81 | const [year, month, day] = string.split("-");
82 | const date = new Date();
83 | date.setFullYear(year);
84 | date.setMonth(month - 1);
85 | date.setDate(day);
86 | return date;
87 | };
88 |
--------------------------------------------------------------------------------
/context/exercise-videos-context.jsx:
--------------------------------------------------------------------------------
1 | import { useState, createContext, useContext } from "react";
2 | import { supabase, generateUrlSuffix } from "../utils/supabase";
3 |
4 | export const ExerciseVideosContext = createContext();
5 |
6 | export function ExerciseVideoContextProvider(props) {
7 | const [exerciseVideos, setExerciseVideos] = useState({});
8 |
9 | const getExerciseVideo = async (idOrIds, refresh = false) => {
10 | const ids = Array.isArray(idOrIds) ? idOrIds : [idOrIds];
11 | console.log("requesting video and poster", ids);
12 |
13 | if (ids.some((id) => !exerciseVideos[id]) || refresh) {
14 | const idsToFetch = refresh
15 | ? ids
16 | : ids.filter((id) => !exerciseVideos[id]);
17 |
18 | const newExerciseVideos = {
19 | ...exerciseVideos,
20 | };
21 |
22 | await Promise.all(
23 | idsToFetch.map(async (id) => {
24 | const { data: list, error: listError } = await supabase.storage
25 | .from("exercise")
26 | .list(id);
27 | if (listError) {
28 | console.error(listError);
29 | }
30 |
31 | const videoDetails = list?.find(({ name }) =>
32 | name.startsWith("video")
33 | );
34 | const imageDetails = list?.find(({ name }) =>
35 | name.startsWith("image")
36 | );
37 |
38 | const { publicURL: url, error: getVideoUrlError } =
39 | await supabase.storage
40 | .from("exercise")
41 | .getPublicUrl(
42 | `${id}/video.mp4?${generateUrlSuffix(videoDetails)}`
43 | );
44 |
45 | const { publicURL: thumbnailUrl, error: getVideoPosterError } =
46 | await supabase.storage
47 | .from("exercise")
48 | .getPublicUrl(
49 | `${id}/image.jpg?${generateUrlSuffix(imageDetails)}`
50 | );
51 |
52 | if (getVideoUrlError || getVideoPosterError) {
53 | console.error(getVideoUrlError || getVideoPosterError);
54 | } else {
55 | newExerciseVideos[id] = {
56 | url,
57 | thumbnailUrl,
58 | };
59 | }
60 | })
61 | );
62 |
63 | console.log("newExerciseVideos", newExerciseVideos);
64 | setExerciseVideos(newExerciseVideos);
65 | } else {
66 | console.log("exercise video cache hit");
67 | }
68 | };
69 |
70 | const value = { exerciseVideos, getExerciseVideo };
71 |
72 | return ;
73 | }
74 |
75 | export function useExerciseVideos() {
76 | const context = useContext(ExerciseVideosContext);
77 | return context;
78 | }
79 |
--------------------------------------------------------------------------------
/pages/api/webhook/stripe/account.js:
--------------------------------------------------------------------------------
1 | /* eslint-disable camelcase */
2 | import { buffer } from "micro";
3 | import Stripe from "stripe";
4 | import { getSupabaseService } from "../../../../utils/supabase";
5 |
6 | const webhookSecret = process.env.STRIPE_ACCOUNT_WEBHOOK_SECRET;
7 |
8 | export const config = {
9 | api: {
10 | bodyParser: false,
11 | },
12 | };
13 |
14 | export default async function handler(req, res) {
15 | const stripe = Stripe(process.env.STRIPE_SECRET_KEY);
16 | const supabase = getSupabaseService();
17 |
18 | if (req.method === "POST") {
19 | const buf = await buffer(req);
20 | const sig = req.headers["stripe-signature"];
21 |
22 | let event;
23 |
24 | try {
25 | event = stripe.webhooks.constructEvent(buf, sig, webhookSecret);
26 | } catch (err) {
27 | res.status(400).send(`Webhook Error: ${err.message}`);
28 | return;
29 | }
30 |
31 | switch (event.type) {
32 | case "account.updated":
33 | {
34 | const account = event.data.object;
35 | const { data: profile } = await supabase
36 | .from("profile")
37 | .select("*")
38 | .eq("stripe_account", account.id)
39 | .single();
40 | if (profile) {
41 | const has_completed_onboarding = account.details_submitted;
42 | const can_coach = account.charges_enabled;
43 | const updates = {};
44 | let needsUpdate = false;
45 | if (profile.has_completed_onboarding !== has_completed_onboarding) {
46 | updates.has_completed_onboarding = has_completed_onboarding;
47 | needsUpdate = true;
48 | }
49 | if (profile.can_coach !== can_coach) {
50 | updates.can_coach = can_coach;
51 | const product = await stripe.products.create({
52 | name: `Coaching by ${profile.email}`,
53 | default_price_data: { currency: "usd", unit_amount: 0 },
54 | images: ["https://www.repsetter.com/images/logo.png"],
55 | });
56 | updates.product_id = product.id;
57 | updates.default_price_id = product.default_price;
58 |
59 | needsUpdate = true;
60 | }
61 | if (needsUpdate) {
62 | await supabase
63 | .from("profile")
64 | .update(updates)
65 | .eq("stripe_account", account.id);
66 | }
67 | }
68 | }
69 | break;
70 | default:
71 | console.log(`Unhandled Event type ${event.type}`);
72 | }
73 |
74 | res.json({ received: true });
75 | } else {
76 | res.setHeader("Allow", "POST");
77 | res.status(405).end("Method Not Allowed");
78 | }
79 | }
80 |
--------------------------------------------------------------------------------
/utils/MiSmartScale2.js:
--------------------------------------------------------------------------------
1 | // https://github.com/oliexdev/openScale/wiki/Xiaomi-Bluetooth-Mi-Scale
2 |
3 | import { EventDispatcher } from "./EventDispatcher";
4 |
5 | export default class MiSmartScale2 extends EventDispatcher {
6 | serviceUUID = "0000181d-0000-1000-8000-00805f9b34fb";
7 | characteristicUUID = "00002a9d-0000-1000-8000-00805f9b34fb";
8 |
9 | get isConnected() {
10 | return this.server?.connected;
11 | }
12 | async connect() {
13 | if (!this.device) {
14 | const device = await navigator.bluetooth.requestDevice({
15 | filters: [{ services: [this.serviceUUID] }],
16 | });
17 | if (device) {
18 | this.device = device;
19 | this.device.addEventListener("gattserverdisconnected", (event) => {
20 | this.dispatchEvent({ type: "disconnected" });
21 | this.device = null;
22 | });
23 | this.server = await this.device.gatt.connect();
24 | this.service = await this.server.getPrimaryService(this.serviceUUID);
25 | this.characteristic = await this.service.getCharacteristic(
26 | this.characteristicUUID
27 | );
28 | this.characteristic.addEventListener(
29 | "characteristicvaluechanged",
30 | this.onCharacteristicValueChanged.bind(this)
31 | );
32 | await this.characteristic.startNotifications();
33 | this.dispatchEvent({ type: "connected" });
34 | }
35 | } else {
36 | if (!this.isConnected) {
37 | await this.device.gatt.connect();
38 | this.dispatchEvent({ type: "connected" });
39 | }
40 | }
41 | }
42 | async disconnect() {
43 | if (this.device) {
44 | await this.device?.gatt?.disconnect();
45 | this.device = null;
46 | }
47 | }
48 |
49 | async open() {
50 | if (this.isConnected) {
51 | await this.characteristic?.startNotifications();
52 | }
53 | }
54 | async close() {
55 | if (this.isConnected) {
56 | await this.characteristic?.stopNotifications();
57 | }
58 | }
59 |
60 | onCharacteristicValueChanged(event) {
61 | const dataView = event.target.value;
62 | const controlByte = dataView.getUint8(0);
63 | const stabilized = Boolean(controlByte & (1 << 5));
64 | let weight = dataView.getUint16(1, true);
65 | let isUsingKilograms = true;
66 | if (Boolean(controlByte & (1 << 0))) {
67 | // lbs
68 | isUsingKilograms = false;
69 | } else if (Boolean(controlByte & (1 << 4))) {
70 | // jin
71 | isUsingKilograms = false;
72 | weight *= 1.1023113109244;
73 | }
74 |
75 | if (isUsingKilograms) {
76 | weight /= 200;
77 | } else {
78 | weight /= 100;
79 | }
80 |
81 | this.dispatchEvent({
82 | type: "weight",
83 | message: { weight, stabilized, isUsingKilograms },
84 | });
85 | }
86 | }
87 |
--------------------------------------------------------------------------------
/pages/api/subscription/delete-subscription.js:
--------------------------------------------------------------------------------
1 | /* eslint-disable camelcase */
2 | /* eslint-disable consistent-return */
3 | import {
4 | getSupabaseService,
5 | getUserProfile,
6 | isUserAdmin,
7 | getUserByAccessToken,
8 | } from "../../../utils/supabase";
9 | import Stripe from "stripe";
10 |
11 | import { updateNumberOfUnredeemedSubscriptions } from "../../../utils/subscription-utils";
12 |
13 | export default async function handler(req, res) {
14 | const sendError = (error) =>
15 | res.status(200).json({
16 | status: {
17 | type: "failed",
18 | title: "Failed to Delete Subscription",
19 | ...error,
20 | },
21 | });
22 |
23 | const supabase = getSupabaseService();
24 | const { user } = await getUserByAccessToken(supabase, req);
25 | if (!user) {
26 | return sendError({ message: "You're not signed in" });
27 | }
28 | if (!req.query.subscriptionId) {
29 | return sendError({ message: "No subscription was specified" });
30 | }
31 | const { subscriptionId } = req.query;
32 |
33 | const { data: subscription } = await supabase
34 | .from("subscription")
35 | .select("*, client(*), coach(*)")
36 | .match({ id: subscriptionId })
37 | .single();
38 |
39 | if (
40 | subscription &&
41 | (subscription.coach.id === user.id ||
42 | subscription.client.id === user.id ||
43 | isUserAdmin(user))
44 | ) {
45 | const profile = await getUserProfile(user, supabase);
46 |
47 | if (subscription.redeemed && subscription.client) {
48 | const { client } = subscription;
49 | if (client.coaches?.includes(subscription.coach.id)) {
50 | const coaches = client.coaches || [];
51 | coaches.splice(coaches.indexOf(subscription.coach.id), 1);
52 |
53 | console.log("updated coaches", coaches);
54 | const updateClientResponse = await supabase
55 | .from("profile")
56 | .update({ coaches })
57 | .eq("id", client.id);
58 | console.log("updateClientResponse", updateClientResponse);
59 | }
60 |
61 | const stripe = Stripe(process.env.STRIPE_SECRET_KEY);
62 | const cancelledStripeSubscription = await stripe.subscriptions.del(
63 | subscription.stripe_id
64 | );
65 | console.log("cancelledStripeSubscription", cancelledStripeSubscription);
66 | }
67 |
68 | const deleteSubscriptionResult = await supabase
69 | .from("subscription")
70 | .delete()
71 | .eq("id", subscriptionId);
72 | console.log("delete subscription result", deleteSubscriptionResult);
73 |
74 | await updateNumberOfUnredeemedSubscriptions(subscription.coach, supabase);
75 |
76 | res.status(200).json({
77 | status: { type: "succeeded", title: "Successfully deleted subscription" },
78 | });
79 | } else {
80 | return sendError({ message: "Subscription isn't yours or doesn't exit" });
81 | }
82 | }
83 |
--------------------------------------------------------------------------------
/context/selected-exercise-context.jsx:
--------------------------------------------------------------------------------
1 | import { useState, createContext, useContext, useEffect } from "react";
2 | import { supabase } from "../utils/supabase";
3 | import { useRouter } from "next/router";
4 |
5 | export const SelectedExerciseTypeContext = createContext();
6 |
7 | export function SelectedExerciseTypeContextProvider(props) {
8 | const router = useRouter();
9 |
10 | const [selectedExerciseType, setSelectedExerciseType] = useState();
11 | const [selectedExerciseTypeName, setSelectedExerciseTypeName] = useState();
12 | const [checkedQuery, setCheckedQuery] = useState(false);
13 |
14 | useEffect(() => {
15 | if (!router.isReady || checkedQuery) {
16 | return;
17 | }
18 | if ("exercise-type" in router.query) {
19 | const selectedExerciseTypeName = router.query["exercise-type"];
20 | setSelectedExerciseTypeName(selectedExerciseTypeName);
21 | }
22 | setCheckedQuery(true);
23 | }, [router.isReady, checkedQuery]);
24 |
25 | useEffect(() => {
26 | if (router.isReady && checkedQuery && selectedExerciseType) {
27 | const query = {};
28 | query["exercise-type"] = selectedExerciseType.name;
29 | router.replace({ query: { ...router.query, ...query } }, undefined, {
30 | shallow: true,
31 | });
32 | }
33 | }, [router.pathname]);
34 |
35 | const [isGettingExerciseType, setIsGettingExerciseType] = useState(false);
36 | const getExerciseType = async () => {
37 | if (isGettingExerciseType) {
38 | return;
39 | }
40 |
41 | setIsGettingExerciseType(true);
42 | const { data: exerciseType, error } = await supabase
43 | .from("exercise_type")
44 | .select("*")
45 | .eq("name", selectedExerciseTypeName)
46 | .maybeSingle();
47 |
48 | if (error) {
49 | console.error(error);
50 | } else {
51 | setSelectedExerciseType(exerciseType);
52 | }
53 | setIsGettingExerciseType(false);
54 | };
55 | useEffect(() => {
56 | if (selectedExerciseTypeName && !selectedExerciseType) {
57 | getExerciseType();
58 | }
59 | }, [selectedExerciseTypeName]);
60 |
61 | useEffect(() => {
62 | if (!router.isReady || !checkedQuery) {
63 | return;
64 | }
65 |
66 | const query = {};
67 | if (selectedExerciseType) {
68 | query["exercise-type"] = selectedExerciseType.name;
69 | } else {
70 | delete router.query["exercise-type"];
71 | }
72 |
73 | router.replace({ query: { ...router.query, ...query } }, undefined, {
74 | shallow: true,
75 | });
76 | }, [selectedExerciseType]);
77 |
78 | const value = {
79 | selectedExerciseType,
80 | setSelectedExerciseType,
81 | selectedExerciseTypeName,
82 | setSelectedExerciseTypeName,
83 | };
84 | return ;
85 | }
86 |
87 | export function useSelectedExerciseType() {
88 | const context = useContext(SelectedExerciseTypeContext);
89 | return context;
90 | }
91 |
--------------------------------------------------------------------------------
/utils/MiBodyCompositionScale.js:
--------------------------------------------------------------------------------
1 | // https://github.com/wiecosystem/Bluetooth/blob/master/doc/devices/huami.health.scale2.md
2 | // https://dev.to/henrylim96/reading-xiaomi-mi-scale-data-with-web-bluetooth-scanning-api-1mb9
3 |
4 | import { EventDispatcher } from "./EventDispatcher";
5 | import Metrics from "./MIBCSMetrics";
6 |
7 | export default class MiBodyCompositionScale extends EventDispatcher {
8 | serviceUUID = "0000181b-0000-1000-8000-00805f9b34fb";
9 | characteristicUUID = "00002a9c-0000-1000-8000-00805f9b34fb";
10 |
11 | get isConnected() {
12 | return this.server?.connected;
13 | }
14 | async connect() {
15 | if (!this.device) {
16 | const device = await navigator.bluetooth.requestDevice({
17 | filters: [{ services: [this.serviceUUID] }],
18 | });
19 | if (device) {
20 | this.device = device;
21 | this.device.addEventListener("gattserverdisconnected", (event) => {
22 | this.dispatchEvent({ type: "disconnected" });
23 | this.device = null;
24 | });
25 | this.server = await this.device.gatt.connect();
26 | this.service = await this.server.getPrimaryService(this.serviceUUID);
27 | this.characteristic = await this.service.getCharacteristic(
28 | this.characteristicUUID
29 | );
30 | this.characteristic.addEventListener(
31 | "characteristicvaluechanged",
32 | this.onCharacteristicValueChanged.bind(this)
33 | );
34 | await this.characteristic.startNotifications();
35 | this.dispatchEvent({ type: "connected" });
36 | }
37 | } else {
38 | if (!this.isConnected) {
39 | await this.device.gatt.connect();
40 | this.dispatchEvent({ type: "connected" });
41 | }
42 | }
43 | }
44 | async disconnect() {
45 | if (this.device) {
46 | await this.device?.gatt?.disconnect();
47 | this.device = null;
48 | }
49 | }
50 |
51 | async open() {
52 | if (this.isConnected) {
53 | await this.characteristic?.startNotifications();
54 | }
55 | }
56 | async close() {
57 | if (this.isConnected) {
58 | await this.characteristic?.stopNotifications();
59 | }
60 | }
61 |
62 | onCharacteristicValueChanged(event) {
63 | const dataView = event.target.value;
64 | const controlByte = dataView.getUint8(1);
65 | const stabilized = Boolean(controlByte & (1 << 5));
66 | let weight = dataView.getUint16(11, true);
67 | let isUsingKilograms = true;
68 | if (isUsingKilograms) {
69 | weight /= 200;
70 | } else {
71 | weight /= 100;
72 | }
73 |
74 | const impedance = dataView.getUint16(9, true);
75 | if (stabilized && impedance > 0 && impedance < 3000) {
76 | const metrics = new Metrics(weight, impedance, 180, 28, "male");
77 | const result = metrics.getResult();
78 | const bodyfatPercentage = result.find(
79 | (result) => result.name === "Fat"
80 | ).value;
81 | // not gonna use bodyfat percentage - complete garbage data
82 | }
83 |
84 | this.dispatchEvent({
85 | type: "weight",
86 | message: { weight, stabilized, isUsingKilograms },
87 | });
88 | }
89 | }
90 |
--------------------------------------------------------------------------------
/pages/api/account/check-stripe.js:
--------------------------------------------------------------------------------
1 | import Stripe from "stripe";
2 | import {
3 | getSupabaseService,
4 | getUserByAccessToken,
5 | getUserProfile,
6 | isUserAdmin,
7 | } from "../../../utils/supabase";
8 |
9 | // eslint-disable-next-line consistent-return
10 | export default async function handler(req, res) {
11 | const stripe = Stripe(process.env.STRIPE_SECRET_KEY);
12 | const supabase = getSupabaseService();
13 |
14 | const sendError = (error) =>
15 | res.status(200).json({
16 | status: {
17 | type: "failed",
18 | title: "Failed to check user's stripe info",
19 | ...error,
20 | },
21 | });
22 |
23 | const { user } = await getUserByAccessToken(supabase, req);
24 | if (!user) {
25 | return sendError({ message: "you are not signed in" });
26 | }
27 |
28 | let userToCheck;
29 | if (req.query.userId) {
30 | if (isUserAdmin(user)) {
31 | const { userId } = req.query;
32 | if (userId) {
33 | const { data: foundUser, error } = await supabase
34 | .from("profile")
35 | .select("*")
36 | .eq("id", userId)
37 | .maybeSingle();
38 |
39 | if (error) {
40 | console.error(error);
41 | return sendError({ message: "unable to find user" });
42 | }
43 | if (foundUser) {
44 | userToCheck = foundUser;
45 | }
46 | } else {
47 | return sendError({ message: "userId no defined" });
48 | }
49 | } else {
50 | return sendError({ message: "you are not authorized to check users" });
51 | }
52 | } else {
53 | userToCheck = user;
54 | }
55 |
56 | console.log("userToCheck", userToCheck);
57 | if (!userToCheck) {
58 | return sendError({ message: "no user found" });
59 | }
60 |
61 | const profile = await getUserProfile(userToCheck, supabase);
62 | console.log("profileToCheck", profile);
63 |
64 | const updatedProfile = {};
65 |
66 | if (!profile.stripe_customer) {
67 | console.log("creating stripe_customer");
68 | const customer = await stripe.customers.create({
69 | email: profile.email,
70 | });
71 | console.log("customer", customer);
72 | updatedProfile.stripe_customer = customer.id;
73 | }
74 |
75 | if (!profile.stripe_account) {
76 | console.log("creating stripe_account");
77 | const account = await stripe.accounts.create({
78 | email: profile.email,
79 | type: "express",
80 | });
81 | console.log("account", account);
82 | updatedProfile.stripe_account = account.id;
83 | }
84 |
85 | if (Object.keys(updatedProfile).length > 0) {
86 | if (updatedProfile.stripe_account) {
87 | Object.assign(updatedProfile, {
88 | product_id: null,
89 | default_price_id: null,
90 | has_completed_onboarding: false,
91 | can_coach: false,
92 | number_of_unredeemed_subscriptions: 0,
93 | });
94 | }
95 | await supabase.from("profile").update(updatedProfile).eq("id", profile.id);
96 | }
97 |
98 | res.status(200).json({
99 | message: "stripe customer updated",
100 | data: updatedProfile,
101 | });
102 | }
103 |
--------------------------------------------------------------------------------
/components/Notification.jsx:
--------------------------------------------------------------------------------
1 | import { Fragment } from 'react';
2 | import { Transition } from '@headlessui/react';
3 | import {
4 | XIcon,
5 | CheckCircleIcon,
6 | XCircleIcon,
7 | ExclamationCircleIcon,
8 | } from '@heroicons/react/outline';
9 |
10 | const icons = {
11 | succeeded: () => (
12 |
13 | ),
14 | warning: () => (
15 |
19 | ),
20 | failed: () => (
21 |
22 | ),
23 | };
24 |
25 | export default function Notification({ open, setOpen, status = {} }) {
26 | const Icon = icons[status.type] || icons.failed;
27 | return (
28 | <>
29 | {/* Global notification live region, render this permanently at the end of the document */}
30 |
34 |
35 | {/* Notification panel, dynamically insert this into the live region when it needs to be displayed */}
36 |
46 |
47 |
48 |
49 |
50 |
51 |
52 |
53 |
54 | {status.title}
55 |
56 |
57 | {status.message}
58 |
59 |
60 |
61 | {
65 | setOpen(false);
66 | }}
67 | >
68 | Close
69 |
70 |
71 |
72 |
73 |
74 |
75 |
76 |
77 |
78 | >
79 | );
80 | }
81 |
--------------------------------------------------------------------------------
/pages/privacy.jsx:
--------------------------------------------------------------------------------
1 | import Head from "next/head";
2 |
3 | export default function Privacy() {
4 | return (
5 | <>
6 |
7 | Privacy Policy - Repsetter
8 |
9 |
10 |
11 |
12 | Privacy Policy
13 |
14 |
15 |
16 |
17 |
User Data
18 |
19 | We store the following data on{" "}
20 |
21 | Supabase's database
22 |
23 | :
24 |
25 |
26 |
27 | Your email address so you can sign in, receive pledge receipts, as
28 | well as optionally receive coaching subscription updates (which is
29 | disabled by default; you need to opt-in to receive emails)
30 |
31 |
32 | Your exercise information, so you can view and edit your exercise,
33 | as well as allow coaches to view and plan your exercises.
34 |
35 |
36 | Your bodyweight information, so you can view and edit your
37 | bodyweight, as well as allow coaches to view your bodyweight.
38 |
39 |
40 | Your progress pictures, so you can view and edit your progress
41 | pictures, as well as allow coaches to view your progress pictures.
42 |
43 |
44 | Stripe customer information, so you can make payments to coaches
45 |
46 |
47 | Stripe account information, so you can receive payments from your
48 | clients
49 |
50 |
51 | Withings account information (optional), so we can update your
52 | bodyweight information when you log new weights from your{" "}
53 |
58 | Withings
59 | {" "}
60 | devices
61 |
62 |
63 |
64 |
65 | We only use this information to make this site work, and we don't
66 | share it with any third parties.
67 |
68 |
69 |
Deleting your Account
70 |
71 | When you delete your account we delete all of the information listed
72 | above as we perform the following:
73 |
74 |
75 |
76 | delete your user data stored in our database, including your email
77 |
78 |
79 | delete your stripe customer data (which stores card information for
80 | making payments)
81 |
82 |
83 | delete your stripe account data (which stores card/bank info for
84 | receiving payments)
85 |
86 |
87 |
88 | >
89 | );
90 | }
91 |
--------------------------------------------------------------------------------
/pages/api/subscription/redeem-subscription.js:
--------------------------------------------------------------------------------
1 | /* eslint-disable camelcase */
2 | /* eslint-disable consistent-return */
3 | import Stripe from "stripe";
4 | import { getSupabaseService, getUserProfile } from "../../../utils/supabase";
5 | import absoluteUrl from "next-absolute-url";
6 | import { repsetterFeePercentage } from "../../../utils/subscription-utils";
7 |
8 | export default async function handler(req, res) {
9 | if (!req.query.subscriptionId) {
10 | return sendError({ message: "No subscription was specified" });
11 | }
12 | const { subscriptionId } = req.query;
13 |
14 | const sendError = (error) => {
15 | return res.redirect(`/subscription/${subscriptionId}`);
16 | return res.status(200).json({
17 | status: {
18 | type: "failed",
19 | title: "Failed to Redeem Coaching Subscription",
20 | ...error,
21 | },
22 | });
23 | };
24 |
25 | const supabase = getSupabaseService();
26 | const { user } = await supabase.auth.api.getUser(req.query.access_token);
27 | if (!user) {
28 | return sendError({ message: "You're not signed in" });
29 | }
30 |
31 | const { data: subscription } = await supabase
32 | .from("subscription")
33 | .select("*, coach(*)")
34 | .match({ id: subscriptionId })
35 | .single();
36 |
37 | if (subscription) {
38 | if (subscription.coach.id === user.id) {
39 | return sendError({ message: "you can't subscribe to yourself" });
40 | }
41 |
42 | const { data: existingSubscription } = await supabase
43 | .from("subscription")
44 | .select("*")
45 | .match({ client: user.id, coach: subscription.coach.id })
46 | .maybeSingle();
47 | if (existingSubscription) {
48 | return sendError({ message: "You're already subscribed to this coach" });
49 | }
50 |
51 | const { origin } = absoluteUrl(req);
52 |
53 | const profile = await getUserProfile(user, supabase);
54 | const stripe = Stripe(process.env.STRIPE_SECRET_KEY);
55 | console.log(subscription.price_id);
56 | if (subscription.price === 0) {
57 | const stripeSubscription = await stripe.subscriptions.create({
58 | customer: profile.stripe_customer,
59 | items: [{ price: subscription.price_id }],
60 | metadata: { subscription: subscription.id, client: profile.id },
61 | transfer_data: {
62 | destination: subscription.coach.stripe_account,
63 | amount_percent: repsetterFeePercentage,
64 | },
65 | });
66 | res.redirect(`/subscription/${subscriptionId}`);
67 | } else {
68 | const session = await stripe.checkout.sessions.create({
69 | mode: "subscription",
70 | customer: profile.stripe_customer,
71 |
72 | line_items: [
73 | {
74 | price: subscription.price_id,
75 | quantity: 1,
76 | },
77 | ],
78 | subscription_data: {
79 | application_fee_percent: repsetterFeePercentage,
80 | transfer_data: {
81 | destination: subscription.coach.stripe_account,
82 | },
83 | metadata: { subscription: subscription.id, client: profile.id },
84 | },
85 |
86 | success_url: `${origin}/subscription/${subscription.id}?session_id={CHECKOUT_SESSION_ID}`,
87 | cancel_url: `${origin}/subscription/${subscription.id}`,
88 | });
89 |
90 | console.log("checkout session", session);
91 | res.redirect(session.url);
92 | }
93 | } else {
94 | return sendError({ message: "subscription not found" });
95 | }
96 | }
97 |
--------------------------------------------------------------------------------
/pages/api/account/sign-in.js:
--------------------------------------------------------------------------------
1 | /* eslint-disable consistent-return */
2 | import { getSupabaseService } from "../../../utils/supabase";
3 |
4 | import sendEmail from "../../../utils/send-email";
5 |
6 | const magicLinkEmail = "magic-link@repsetter.com";
7 | const recoveryEmailLimit = 1000 * 60; // ms
8 |
9 | export default async function handler(req, res) {
10 | const allowedDomains = process.env.MAGIC_LINK_REDIRECT_URLS.split(",");
11 |
12 | const sendError = (error) =>
13 | res.status(200).json({
14 | status: {
15 | type: "failed",
16 | title: "Failed to send Magic Link",
17 | ...error,
18 | },
19 | });
20 | if (req.method !== "POST") {
21 | return sendError({ message: 'must send a "POST" message' });
22 | }
23 |
24 | console.log(JSON.stringify(req.body));
25 | const { email, redirectTo } = req.body;
26 | if (!email) {
27 | return sendError({ message: "no email defined" });
28 | }
29 | if (
30 | redirectTo &&
31 | !allowedDomains.some((allowedDomain) =>
32 | redirectTo.startsWith(allowedDomain)
33 | )
34 | ) {
35 | return sendError({
36 | message: `invalid redirectTo url "${redirectTo}"`,
37 | });
38 | }
39 | console.log(email, redirectTo);
40 |
41 | const supabase = getSupabaseService();
42 | const { data: profile, error: getProfileError } = await supabase
43 | .from("profile")
44 | .select("*")
45 | .eq("email", email)
46 | .maybeSingle();
47 |
48 | if (getProfileError) {
49 | console.error(getProfileError);
50 | return sendError({ message: getProfileError.message });
51 | }
52 |
53 | console.log("profile", profile);
54 |
55 | if (profile) {
56 | const { data: user, error: getUserError } =
57 | await supabase.auth.api.getUserById(profile.id);
58 | if (getUserError) {
59 | console.error(getUserError);
60 | return sendError({ message: getUserError.message });
61 | }
62 | console.log("user", user);
63 | const currentDate = new Date();
64 | const lastTime = new Date(user.recovery_sent_at);
65 | const timeSinceLastRecovery = currentDate.getTime() - lastTime.getTime();
66 | console.log("timeSinceLastRecovery", timeSinceLastRecovery);
67 | if (timeSinceLastRecovery < recoveryEmailLimit) {
68 | return sendError({
69 | title: "Magic Link already sent",
70 | message:
71 | "A link was already emailed to this address less than a minute ago. Check your spam folder if you can't find it.",
72 | });
73 | }
74 | }
75 |
76 | const { data, generateLinkError } = await supabase.auth.api.generateLink(
77 | "magiclink",
78 | email,
79 | {
80 | redirectTo,
81 | }
82 | );
83 | if (generateLinkError) {
84 | console.error(generateLinkError);
85 | return sendError({ message: generateLinkError.message });
86 | }
87 |
88 | await sendEmail({
89 | to: email,
90 | subject: "Sign in to Repsetter",
91 | from: {
92 | email: magicLinkEmail,
93 | },
94 | templateId: process.env.SENDGRID_MAGIC_LINK_TEMPLATE_ID,
95 | dynamicTemplateData: {
96 | heading: "Your Repsetter Magic Link",
97 | body: "Follow this link to sign in to Repsetter:",
98 | optional_link: "Sign In",
99 | optional_link_url: data.action_link,
100 | },
101 | });
102 |
103 | res.status(200).json({
104 | status: {
105 | type: "succeeded",
106 | title: "Successfully sent Magic Link",
107 | },
108 | });
109 | }
110 |
--------------------------------------------------------------------------------
/components/dashboard/ClientsSelect.jsx:
--------------------------------------------------------------------------------
1 | import { useClient } from "../../context/client-context";
2 | import { useUser } from "../../context/user-context";
3 | import { useEffect } from "react";
4 |
5 | function classNames(...classes) {
6 | return classes.filter(Boolean).join(" ");
7 | }
8 |
9 | export default function ClientsSelect({
10 | showBlocks = false,
11 | showClients = true,
12 | name,
13 | className,
14 | }) {
15 | const { user, isAdmin } = useUser();
16 |
17 | const {
18 | clients,
19 | getClients,
20 | selectedClient,
21 | setSelectedClient,
22 | blocks,
23 | selectedClientId,
24 | selectedBlock,
25 | setSelectedBlock,
26 | getBlocks,
27 | } = useClient();
28 | useEffect(() => {
29 | if (!clients) {
30 | getClients();
31 | }
32 | }, [clients]);
33 |
34 | useEffect(() => {
35 | if (showBlocks && selectedClientId) {
36 | getBlocks();
37 | }
38 | }, [selectedClientId]);
39 |
40 | return (
41 | (clients?.length > 0 || blocks?.length > 0) && (
42 |
43 | {
52 | const isBlock = e.target.selectedOptions[0].dataset.block;
53 | if (isBlock) {
54 | setSelectedBlock(
55 | blocks.find((block) => block.id === e.target.value)
56 | );
57 | } else {
58 | setSelectedBlock();
59 | setSelectedClient(
60 | e.target.value === user.email
61 | ? null
62 | : clients.find(
63 | (client) => client.client_email === e.target.value
64 | )
65 | );
66 | }
67 | }}
68 | >
69 | {showClients && (
70 | <>
71 | Me
72 | {clients?.length > 0 && (
73 |
74 | {clients?.map((client) => (
75 |
79 | {client.client_email}
80 |
81 | ))}
82 |
83 | )}
84 | >
85 | )}
86 | {showBlocks && blocks?.length > 0 && (
87 |
97 | {blocks?.map((block) => (
98 |
99 | {block.name}
100 |
101 | ))}
102 |
103 | )}
104 |
105 |
106 | )
107 | );
108 | }
109 |
--------------------------------------------------------------------------------
/pages/faq.jsx:
--------------------------------------------------------------------------------
1 | import { Disclosure } from "@headlessui/react";
2 | import { ChevronDownIcon } from "@heroicons/react/outline";
3 | import Head from "next/head";
4 |
5 | const faqs = [
6 | {
7 | question: "Are there any fees to get started?",
8 | answer: () => (
9 | <>
10 |
11 | Nope. You can sign up for free and just use this as standalone workout
12 | app.
13 |
14 | >
15 | ),
16 | },
17 | {
18 | question: "Can I cancel my coaching subscription anytime?",
19 | answer: () => (
20 | <>
21 | Yep. You're free to cancel anytime.
22 | >
23 | ),
24 | },
25 | {
26 | question: "Can anyone be a coach?",
27 | answer: () => (
28 | <>
29 |
30 | Only people 18 years and older in the United States can be coaches.
31 |
32 | >
33 | ),
34 | },
35 | {
36 | question: "What percent does Repsetter take from coaching subscriptions?",
37 | answer: () => (
38 | <>
39 | We take a flat 5% fee from coaching subscriptions.
40 | >
41 | ),
42 | },
43 | {
44 | question: "I can't play Google Drive videos on iOS Safari!",
45 | answer: () => (
46 | <>
47 |
48 | Because of how Google Drive embeds work, you'll either have to
49 | tap the button in the player that opens it in Google Drive, or allow
50 | cross-site tracking by setting Prevent Cross-Site Tracking to{" "}
51 | OFF in your iOS Settings
52 |
53 | >
54 | ),
55 | },
56 | ];
57 |
58 | function classNames(...classes) {
59 | return classes.filter(Boolean).join(" ");
60 | }
61 |
62 | export default function FAQ() {
63 | return (
64 | <>
65 |
66 | FAQ - Repsetter
67 |
68 |
69 |
70 | Frequently Asked Questions
71 |
72 |
73 | {faqs.map((faq) => (
74 |
75 | {({ open }) => (
76 | <>
77 |
78 |
79 |
80 | {faq.question}
81 |
82 |
83 |
90 |
91 |
92 |
93 |
97 |
98 |
99 | >
100 | )}
101 |
102 | ))}
103 |
104 |
105 | >
106 | );
107 | }
108 |
--------------------------------------------------------------------------------
/utils/wii-balance-board/WiiBalanceBoard.js:
--------------------------------------------------------------------------------
1 | import {
2 | ReportMode,
3 | DataReportMode,
4 | LEDS,
5 | BUTTON_BYTE1,
6 | BUTTON_BYTE2,
7 | InputReport,
8 | WiiBalanceBoardPositions,
9 | } from "./const.js";
10 |
11 | import Wiimote from "./Wiimote.js";
12 |
13 | export default class WiiBalanceBoard extends Wiimote {
14 | constructor(device) {
15 | super(device);
16 |
17 | this.WeightListener = null;
18 | this.weights = {
19 | TOP_RIGHT: 0,
20 | BOTTOM_RIGHT: 1,
21 | TOP_LEFT: 2,
22 | BOTTOM_LEFT: 3,
23 | };
24 |
25 | this.calibration = [
26 | [10000.0, 10000.0, 10000.0, 10000.0],
27 | [10000.0, 10000.0, 10000.0, 10000.0],
28 | [10000.0, 10000.0, 10000.0, 10000.0],
29 | ];
30 | }
31 |
32 | // Initiliase the Wiimote
33 | initiateDevice() {
34 | this.device.open().then(() => {
35 | this.sendReport(ReportMode.STATUS_INFO_REQ, [0x00]);
36 | this.sendReport(
37 | ReportMode.MEM_REG_READ,
38 | [0x04, 0xa4, 0x00, 0x24, 0x00, 0x18]
39 | );
40 | this.setDataTracking(DataReportMode.EXTENSION_8BYTES);
41 |
42 | this.device.oninputreport = (e) => this.listener(e);
43 | });
44 | }
45 |
46 | WeightCalibrationDecoder(data) {
47 | const length = data.getUint8(2) / 16 + 1;
48 | if (length == 16) {
49 | [0, 1].forEach((i) => {
50 | this.calibration[i] = [0, 1, 2, 3].map((j) =>
51 | data.getUint16(4 + i * 8 + 2 * j, true)
52 | );
53 | });
54 | } else if (length == 8) {
55 | this.calibration[2] = [0, 1, 2, 3].map((j) =>
56 | data.getUint16(4 + 2 * j, true)
57 | );
58 | }
59 | }
60 |
61 | WeightDecoder(data) {
62 | const weights = [0, 1, 2, 3].map((i) => {
63 | const raw = data.getUint16(2 + 2 * i, false);
64 | //return raw;
65 | if (raw < this.calibration[0][i]) {
66 | return 0;
67 | } else if (raw < this.calibration[1][i]) {
68 | return (
69 | 17 *
70 | ((raw - this.calibration[0][i]) /
71 | (this.calibration[1][i] - this.calibration[0][i]))
72 | );
73 | } else {
74 | return (
75 | 17 +
76 | 17 *
77 | ((raw - this.calibration[1][i]) /
78 | (this.calibration[2][i] - this.calibration[1][i]))
79 | );
80 | }
81 | });
82 | const total = weights.reduce((sum, value) => sum + value, 0);
83 |
84 | for (let position in WiiBalanceBoardPositions) {
85 | const index = WiiBalanceBoardPositions[position];
86 | this.weights[position] = weights[index];
87 | }
88 |
89 | this.weights.total = total;
90 |
91 | if (this.WeightListener) {
92 | this.WeightListener(this.weights);
93 | }
94 | }
95 |
96 | // main listener received input from the Wiimote
97 | listener(event) {
98 | var { data } = event;
99 |
100 | switch (event.reportId) {
101 | case InputReport.STATUS:
102 | console.log("status");
103 | break;
104 | case InputReport.READ_MEM_DATA:
105 | // calibration data
106 | console.log("calibration data");
107 | this.WeightCalibrationDecoder(data);
108 | break;
109 | case DataReportMode.EXTENSION_8BYTES:
110 | // weight data
111 |
112 | // button data
113 | this.BTNDecoder(...[0, 1].map((i) => data.getUint8(i)));
114 |
115 | // raw weight data
116 | this.WeightDecoder(data);
117 |
118 | // weight listener
119 | break;
120 | default:
121 | console.log(`event of unused report id ${event.reportId}`);
122 | break;
123 | }
124 | }
125 | }
126 |
--------------------------------------------------------------------------------
/components/dashboard/modal/DeletePictureModal.jsx:
--------------------------------------------------------------------------------
1 | /* eslint-disable react/destructuring-assignment */
2 | import { useState, useEffect } from "react";
3 | import Modal from "../../Modal";
4 | import { supabase, dateToString } from "../../../utils/supabase";
5 | import { useUser } from "../../../context/user-context";
6 | import { useClient } from "../../../context/client-context";
7 |
8 | export default function DeletePictureModal(props) {
9 | const {
10 | open,
11 | setOpen,
12 | types: pictureTypes,
13 | setTypes: setPictureTypes,
14 | setDeleteResultStatus: setDeletePictureStatus,
15 | setShowDeleteResultNotification: setShowDeletePictureNotification,
16 | } = props;
17 | const [isDeleting, setIsDeleting] = useState(false);
18 | const [didDelete, setDidDelete] = useState(false);
19 |
20 | const { selectedDate } = useClient();
21 | const { user } = useUser();
22 |
23 | useEffect(() => {
24 | if (open) {
25 | setIsDeleting(false);
26 | setDidDelete(false);
27 | }
28 | }, [open]);
29 |
30 | const resultName = `Picture${pictureTypes?.length > 1 ? "s" : ""}`;
31 |
32 | return (
33 | 1 ? "these pictures" : "this picture"
38 | }? This action cannot be undone.`}
39 | color="red"
40 | Button={
41 | {
44 | console.log("DELETING", pictureTypes);
45 | setIsDeleting(true);
46 |
47 | console.log(
48 | pictureTypes.map(
49 | (pictureType) =>
50 | `${user.id}/${dateToString(selectedDate)}_${pictureType}.jpg`
51 | )
52 | );
53 |
54 | let status;
55 | const { data: removePicturesData, error: removePicturesError } =
56 | await supabase.storage
57 | .from("picture")
58 | .remove(
59 | pictureTypes.map(
60 | (pictureType) =>
61 | `${user.id}/${dateToString(
62 | selectedDate
63 | )}_${pictureType}.jpg`
64 | )
65 | );
66 |
67 | console.log("removePicturesData", removePicturesData);
68 |
69 | if (removePicturesError) {
70 | status = {
71 | type: "failed",
72 | title: `Failed to Delete ${resultName}`,
73 | message: removePicturesError.message,
74 | };
75 | } else {
76 | status = {
77 | type: "succeeded",
78 | title: `Successfully deleted ${resultName}`,
79 | };
80 | }
81 |
82 | console.log("status", status);
83 | setIsDeleting(false);
84 | setDidDelete(true);
85 | setDeletePictureStatus(status);
86 | setShowDeletePictureNotification(true);
87 | setOpen(false);
88 | setPictureTypes?.();
89 | }}
90 | className="inline-flex w-full justify-center rounded-md border border-transparent bg-red-600 px-4 py-2 text-base font-medium text-white shadow-sm hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-red-500 focus:ring-offset-2 sm:ml-3 sm:w-auto sm:text-sm"
91 | >
92 | {/* eslint-disable-next-line no-nested-ternary */}
93 | {isDeleting
94 | ? `Deleting ${resultName}...`
95 | : didDelete
96 | ? `Deleted ${resultName}!`
97 | : `Delete ${resultName}`}
98 |
99 | }
100 | >
101 | );
102 | }
103 |
--------------------------------------------------------------------------------
/pages/dashboard/blocks.jsx:
--------------------------------------------------------------------------------
1 | import { useEffect, useState } from "react";
2 | import { useRouter } from "next/router";
3 | import { useUser } from "../../context/user-context";
4 | import { useClient } from "../../context/client-context";
5 | import { getDashboardLayout } from "../../components/layouts/DashboardLayout";
6 | import Table from "../../components/Table";
7 | import MyLink from "../../components/MyLink";
8 | import DeleteBlockModal from "../../components/dashboard/modal/DeleteBlockModal";
9 | import BlockModal from "../../components/dashboard/modal/BlockModal";
10 |
11 | const filterTypes = [];
12 |
13 | const orderTypes = [
14 | {
15 | label: "Date Created",
16 | query: "date-created",
17 | value: ["created_at", { ascending: false }],
18 | current: true,
19 | },
20 | {
21 | label: "Name",
22 | query: "name",
23 | value: ["name", { ascending: true }],
24 | current: false,
25 | },
26 | {
27 | label: "Number of Weeks",
28 | query: "number-of-weeks",
29 | value: ["number_of_weeks", { ascending: true }],
30 | current: false,
31 | },
32 | ];
33 |
34 | export default function Blocks() {
35 | const router = useRouter();
36 |
37 | const { isAdmin, user } = useUser();
38 |
39 | const {
40 | selectedClientId,
41 | selectedClient,
42 | setSelectedDate,
43 | amITheClient,
44 | setSelectedBlock,
45 | } = useClient();
46 |
47 | const [baseFilter, setBaseFilter] = useState();
48 | useEffect(() => {
49 | if (!selectedClientId) {
50 | return;
51 | }
52 |
53 | const newBaseFilter = {
54 | user: false && isAdmin ? selectedClientId : user.id,
55 | };
56 |
57 | setBaseFilter(newBaseFilter);
58 | }, [selectedClientId, user]);
59 |
60 | return (
61 | <>
62 | [
82 | false &&
83 | !amITheClient && {
84 | title: "created by",
85 | value: result.user_email,
86 | },
87 | {
88 | title: "name",
89 | value: result.name,
90 | },
91 | result.description?.length > 0 && {
92 | title: "description",
93 | value: result.description,
94 | },
95 | {
96 | title: "number of weeks",
97 | value: result.number_of_weeks,
98 | },
99 | {
100 | title: "date created",
101 | value: new Date(result.created_at).toLocaleString(),
102 | },
103 | {
104 | jsx: (
105 | {
107 | setSelectedBlock(result);
108 | }}
109 | href={`/dashboard/diary?block=${result.id}`}
110 | className="inline-flex items-center rounded-md border border-gray-300 bg-white px-2.5 py-1.5 text-sm font-medium leading-4 text-gray-700 shadow-sm hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-30"
111 | >
112 | Edit block
113 |
114 | ),
115 | },
116 | ]}
117 | >
118 | >
119 | );
120 | }
121 |
122 | Blocks.getLayout = getDashboardLayout;
123 |
--------------------------------------------------------------------------------
/pages/api/webhook/withings/data.js:
--------------------------------------------------------------------------------
1 | /* eslint-disable camelcase */
2 | import { getSupabaseService } from "../../../../utils/supabase";
3 | import {
4 | getWithingsMeasure,
5 | refreshWithingsAccessToken,
6 | } from "../../../../utils/withings";
7 | import { dateToString } from "../../../../utils/supabase";
8 |
9 | export default async function handler(req, res) {
10 | const supabase = getSupabaseService();
11 |
12 | const { userid, startdate, enddate, appli } =
13 | typeof req.body === "string" ? JSON.parse(req.body) : req.body;
14 | console.log(req.body, typeof req.body);
15 |
16 | console.log("going to fetch profile...", userid);
17 | const { data: profile, error: getProfileError } = await supabase
18 | .from("profile")
19 | .select("*")
20 | .eq("withings_userid", userid)
21 | .maybeSingle();
22 |
23 | console.log("profile", profile);
24 | console.log("getProfileError", getProfileError);
25 |
26 | if (profile && profile.withings_refresh_token) {
27 | const refreshResponseJSON = await refreshWithingsAccessToken(
28 | profile.withings_refresh_token
29 | );
30 | if (refreshResponseJSON.status == 0) {
31 | const { access_token, refresh_token, expires_in } =
32 | refreshResponseJSON.body;
33 |
34 | await supabase
35 | .from("profile")
36 | .update({
37 | withings_access_token: access_token,
38 | withings_refresh_token: refresh_token,
39 | withings_token_expiration: expires_in,
40 | })
41 | .eq("id", profile.id);
42 |
43 | const measureJSON = await getWithingsMeasure(
44 | access_token,
45 | startdate,
46 | enddate
47 | );
48 | console.log("measureJSON", measureJSON);
49 | if (measureJSON.status === 0) {
50 | const newWeightData = [];
51 | const { timezone } = measureJSON.body;
52 | measureJSON.body.measuregrps.forEach((measuregrp) => {
53 | const date = new Date(measuregrp.date * 1000);
54 | console.log("date", date);
55 | const newWeightDatum = {
56 | //date: dateToString(date),
57 | date: date.toLocaleDateString("en-US", { timeZone: timezone }),
58 | time: date.toLocaleTimeString("en-US", {
59 | timeZone: timezone,
60 | hour12: false,
61 | timeStyle: "short",
62 | }),
63 | client: profile.id,
64 | client_email: profile.email,
65 | };
66 | let addWeightDatum = false;
67 | measuregrp.measures.forEach((measure) => {
68 | switch (measure.type) {
69 | case 1:
70 | const weightInKilograms = measure.value * 10 ** measure.unit;
71 | newWeightDatum.weight = weightInKilograms;
72 | newWeightDatum.is_weight_in_kilograms = true;
73 | addWeightDatum = true;
74 | console.log("weightInKilograms", weightInKilograms);
75 | break;
76 | case 6:
77 | const bodyfatPercentage = measure.value * 10 ** measure.unit;
78 | newWeightDatum.bodyfat_percentage = bodyfatPercentage;
79 | console.log("bodyfatPercentage", bodyfatPercentage);
80 | addWeightDatum = true;
81 | break;
82 | default:
83 | break;
84 | }
85 | });
86 |
87 | if (addWeightDatum) {
88 | newWeightData.push(newWeightDatum);
89 | }
90 | });
91 |
92 | console.log("newWeightData", newWeightData);
93 | if (newWeightData.length) {
94 | const { data: addedWeight, error: addWeightError } = await supabase
95 | .from("weight")
96 | .insert(newWeightData);
97 | if (addWeightError) {
98 | console.error(addWeightError);
99 | } else {
100 | console.log("addedWeight", addedWeight);
101 | }
102 | }
103 | }
104 | }
105 | }
106 | console.log("Sending OK");
107 | res.status(200).send("OK");
108 | }
109 |
--------------------------------------------------------------------------------
/components/dashboard/modal/DeleteWeightModal.jsx:
--------------------------------------------------------------------------------
1 | /* eslint-disable react/destructuring-assignment */
2 | import { useState, useEffect } from "react";
3 | import Modal from "../../Modal";
4 | import { supabase } from "../../../utils/supabase";
5 |
6 | export default function DeleteWeightModal(props) {
7 | const {
8 | open,
9 | setOpen,
10 | selectedResult: selectedWeight,
11 | setSelectedResult: setSelectedWeight,
12 | selectedResults: selectedWeights,
13 | setSelectedResults: setSelectedWeights,
14 | setDeleteResultStatus: setDeleteWeightStatus,
15 | setShowDeleteResultNotification: setShowDeleteWeightNotification,
16 | } = props;
17 | const [isDeleting, setIsDeleting] = useState(false);
18 | const [didDelete, setDidDelete] = useState(false);
19 |
20 | useEffect(() => {
21 | if (open) {
22 | setIsDeleting(false);
23 | setDidDelete(false);
24 | }
25 | }, [open]);
26 |
27 | const resultName = `Weight${selectedWeights?.length > 1 ? "s" : ""}`;
28 |
29 | return (
30 | 1 ? "these weights" : "this weight"
35 | }? This action cannot be undone.`}
36 | color="red"
37 | Button={
38 | {
41 | console.log("DELETING", selectedWeight, selectedWeights);
42 | setIsDeleting(true);
43 |
44 | let status;
45 | let error;
46 |
47 | if (selectedWeight) {
48 | const { data: deleteWeightResult, error: deleteWeightError } =
49 | await supabase
50 | .from("weight")
51 | .delete()
52 | .eq("id", selectedWeight.id);
53 | console.log("deleteWeightResult", deleteWeightResult);
54 | if (deleteWeightError) {
55 | console.error(deleteWeightError);
56 | error = deleteWeightError;
57 | }
58 | } else if (selectedWeights) {
59 | const { data: deleteWeightsResult, error: deleteWeightsError } =
60 | await supabase
61 | .from("weight")
62 | .delete()
63 | .in(
64 | "id",
65 | selectedWeights.map((weight) => weight.id)
66 | );
67 | console.log("deleteWeightsResult", deleteWeightsResult);
68 | if (deleteWeightsError) {
69 | console.error(deleteWeightsError);
70 | error = deleteWeightsError;
71 | }
72 | }
73 |
74 | if (error) {
75 | status = {
76 | type: "failed",
77 | title: `Failed to delete ${resultName}`,
78 | message: error.message,
79 | };
80 | } else {
81 | status = {
82 | type: "succeeded",
83 | title: `Successfully deleted ${resultName}`,
84 | };
85 | }
86 |
87 | console.log("status", status);
88 | setIsDeleting(false);
89 | setDidDelete(true);
90 | setDeleteWeightStatus(status);
91 | setShowDeleteWeightNotification(true);
92 | setOpen(false);
93 | setSelectedWeight?.();
94 | setSelectedWeights?.();
95 | }}
96 | className="inline-flex w-full justify-center rounded-md border border-transparent bg-red-600 px-4 py-2 text-base font-medium text-white shadow-sm hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-red-500 focus:ring-offset-2 sm:ml-3 sm:w-auto sm:text-sm"
97 | >
98 | {/* eslint-disable-next-line no-nested-ternary */}
99 | {isDeleting
100 | ? `Deleting ${resultName}...`
101 | : didDelete
102 | ? `Deleted ${resultName}!`
103 | : `Delete ${resultName}`}
104 |
105 | }
106 | >
107 | );
108 | }
109 |
--------------------------------------------------------------------------------
/pages/dashboard/my-clients.jsx:
--------------------------------------------------------------------------------
1 | import { getDashboardLayout } from "../../components/layouts/DashboardLayout";
2 | import DeleteSubscriptionModal from "../../components/dashboard/modal/DeleteSubscriptionModal";
3 | import Table from "../../components/Table";
4 | import CreateSubscriptionModal from "../../components/dashboard/modal/CreateSubscriptionModal";
5 | import { useUser } from "../../context/user-context";
6 | import { formatDollars } from "../../utils/subscription-utils";
7 | import MyLink from "../../components/MyLink";
8 | import { useEffect, useState } from "react";
9 |
10 | const filterTypes = [
11 | {
12 | name: "Redeemed?",
13 | query: "redeemed",
14 | column: "redeemed",
15 | radios: [
16 | { value: true, label: "yes", defaultChecked: false },
17 | { value: false, label: "no", defaultChecked: false },
18 | { value: null, label: "either", defaultChecked: true },
19 | ],
20 | },
21 | ];
22 |
23 | const orderTypes = [
24 | {
25 | label: "Date Created",
26 | query: "date-created",
27 | value: ["created_at", { ascending: false }],
28 | current: false,
29 | },
30 | {
31 | label: "Date Redeemed",
32 | query: "date-redeemed",
33 | value: ["redeemed_at", { ascending: false }],
34 | current: false,
35 | },
36 | {
37 | label: "Client Email",
38 | query: "client-email",
39 | value: ["client_email", { ascending: true }],
40 | current: false,
41 | },
42 | {
43 | label: "Price",
44 | query: "price",
45 | value: ["price", { ascending: false }],
46 | current: false,
47 | },
48 | ];
49 |
50 | export default function MyClients() {
51 | const { user } = useUser();
52 |
53 | const [baseFilter, setBaseFilter] = useState();
54 | useEffect(() => {
55 | if (user) {
56 | setBaseFilter({ coach: user.id });
57 | }
58 | }, [user]);
59 |
60 | return (
61 | <>
62 |
73 | [
74 | {
75 | title: "created at",
76 | value: new Date(result.created_at).toLocaleString(),
77 | },
78 | {
79 | title: "price",
80 | value: `${formatDollars(result.price, false)}/month`,
81 | },
82 | {
83 | title: "redeemed?",
84 | value: result.redeemed ? "yes" : "no",
85 | },
86 | result.client && {
87 | title: "client",
88 | value: result.client_email,
89 | },
90 | result.redeemed && {
91 | title: "redeemed at",
92 | value: new Date(result.redeemed_at).toLocaleString(),
93 | },
94 | result.redeemed && {
95 | title: "active?",
96 | value: result.is_active ? "yes" : "no",
97 | },
98 | result.redeemed && {
99 | title: "cancelled?",
100 | value: result.is_cancelled ? "yes" : "no",
101 | },
102 | !result.redeemed && {
103 | jsx: (
104 |
108 | View subscription
109 |
110 | ),
111 | },
112 | ].filter(Boolean)
113 | }
114 | >
115 | >
116 | );
117 | }
118 |
119 | MyClients.getLayout = getDashboardLayout;
120 |
--------------------------------------------------------------------------------
/components/dashboard/modal/DeleteExerciseTypeModal.jsx:
--------------------------------------------------------------------------------
1 | /* eslint-disable react/destructuring-assignment */
2 | import { useState, useEffect } from "react";
3 | import Modal from "../../Modal";
4 | import { supabase } from "../../../utils/supabase";
5 |
6 | export default function DeleteExerciseTypeModal(props) {
7 | const {
8 | open,
9 | setOpen,
10 | selectedResult: selectedExerciseType,
11 | setDeleteResultStatus: setDeleteExerciseTypeStatus,
12 | setShowDeleteResultNotification: setShowDeleteExerciseTypeNotification,
13 | } = props;
14 | const [isDeleting, setIsDeleting] = useState(false);
15 | const [didDelete, setDidDelete] = useState(false);
16 |
17 | useEffect(() => {
18 | if (open) {
19 | setIsDeleting(false);
20 | setDidDelete(false);
21 | }
22 | }, [open]);
23 |
24 | return (
25 | {
34 | console.log("DELETING", selectedExerciseType);
35 | setIsDeleting(true);
36 |
37 | const { data: deleteExercisesResult, error: deleteExercisesError } =
38 | await supabase
39 | .from("exercise")
40 | .delete()
41 | .match({ type: selectedExerciseType.id });
42 | console.log("deleteExercisesResult", deleteExercisesResult);
43 | if (deleteExercisesError) {
44 | console.error(deleteExercisesError);
45 | }
46 |
47 | const {
48 | data: deleteExerciseTypeResult,
49 | error: deleteExerciseTypeError,
50 | } = await supabase
51 | .from("exercise_type")
52 | .delete()
53 | .eq("id", selectedExerciseType.id);
54 | console.log("deleteExerciseTypeResult", deleteExerciseTypeResult);
55 | if (deleteExerciseTypeError) {
56 | console.error(deleteExerciseTypeError);
57 | }
58 |
59 | const { data: deleteVideoResult, error: deleteExerciseVideoError } =
60 | await supabase.storage
61 | .from("exercise")
62 | .remove([`${selectedExerciseType.id}/video.mp4`]);
63 | console.log("deleteVideoResult", deleteVideoResult);
64 | if (deleteExerciseVideoError) {
65 | console.error(deleteExerciseVideoError);
66 | }
67 |
68 | const {
69 | data: deletePictureResult,
70 | error: deleteExercisePictureError,
71 | } = await supabase.storage
72 | .from("exercise")
73 | .remove([`${selectedExerciseType.id}/image.jpg`]);
74 | console.log("deletePictureResult", deletePictureResult);
75 | if (deleteExercisePictureError) {
76 | console.error(deleteExercisePictureError);
77 | }
78 |
79 | setIsDeleting(false);
80 | setDidDelete(true);
81 | const status = {
82 | type: "succeeded",
83 | title: "Successfully deleted Exercise Type",
84 | };
85 | console.log("status", status);
86 | setDeleteExerciseTypeStatus(status);
87 | setShowDeleteExerciseTypeNotification(true);
88 | setOpen(false);
89 | }}
90 | className="inline-flex w-full justify-center rounded-md border border-transparent bg-red-600 px-4 py-2 text-base font-medium text-white shadow-sm hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-red-500 focus:ring-offset-2 sm:ml-3 sm:w-auto sm:text-sm"
91 | >
92 | {/* eslint-disable-next-line no-nested-ternary */}
93 | {isDeleting
94 | ? "Deleting Exercise Type..."
95 | : didDelete
96 | ? "Deleted Exercise Type!"
97 | : "Delete Exercise Type"}
98 |
99 | }
100 | >
101 | );
102 | }
103 |
--------------------------------------------------------------------------------
/components/dashboard/modal/DeleteExerciseModal.jsx:
--------------------------------------------------------------------------------
1 | /* eslint-disable react/destructuring-assignment */
2 | import { useState, useEffect } from "react";
3 | import Modal from "../../Modal";
4 | import { supabase } from "../../../utils/supabase";
5 |
6 | export default function DeleteExerciseModal(props) {
7 | const {
8 | open,
9 | setOpen,
10 | selectedResult: selectedExercise,
11 | selectedResults: selectedExercises,
12 | setSelectedResult: setSelectedExercise,
13 | setSelectedResults: setSelectedExercises,
14 | setDeleteResultStatus: setDeleteExerciseStatus,
15 | setShowDeleteResultNotification: setShowDeleteExerciseNotification,
16 | } = props;
17 | const [isDeleting, setIsDeleting] = useState(false);
18 | const [didDelete, setDidDelete] = useState(false);
19 |
20 | useEffect(() => {
21 | if (open) {
22 | setIsDeleting(false);
23 | setDidDelete(false);
24 | }
25 | }, [open]);
26 |
27 | const resultName = `Exercise${selectedExercises?.length > 1 ? "s" : ""}`;
28 |
29 | return (
30 | 1 ? "these exercises" : "this exercise"
35 | }? This action cannot be undone.`}
36 | color="red"
37 | Button={
38 | {
41 | console.log("DELETING", selectedExercise, selectedExercises);
42 | setIsDeleting(true);
43 | let status;
44 | let error;
45 |
46 | if (selectedExercise) {
47 | const { data: deleteExerciseResult, error: deleteExerciseError } =
48 | await supabase
49 | .from("exercise")
50 | .delete()
51 | .eq("id", selectedExercise.id);
52 | console.log("deleteExerciseResult", deleteExerciseResult);
53 | if (deleteExerciseError) {
54 | console.error(deleteExerciseError);
55 | error = deleteExerciseError;
56 | }
57 | } else if (selectedExercises) {
58 | const {
59 | data: deleteExercisesResult,
60 | error: deleteExercisesError,
61 | } = await supabase
62 | .from("exercise")
63 | .delete()
64 | .in(
65 | "id",
66 | selectedExercises.map((exercise) => exercise.id)
67 | );
68 | console.log("deleteExercisesResult", deleteExercisesResult);
69 | if (deleteExercisesError) {
70 | console.error(deleteExercisesError);
71 | error = deleteExercisesError;
72 | }
73 | }
74 |
75 | setIsDeleting(false);
76 | setDidDelete(true);
77 | if (error) {
78 | status = {
79 | type: "failed",
80 | title: `Failed to delete ${resultName}`,
81 | message: error.message,
82 | };
83 | } else {
84 | status = {
85 | type: "succeeded",
86 | title: `Successfully deleted ${resultName}`,
87 | };
88 | }
89 | console.log("status", status);
90 | setDeleteExerciseStatus(status);
91 | setShowDeleteExerciseNotification(true);
92 | setOpen(false);
93 | setSelectedExercise?.();
94 | setSelectedExercises?.();
95 | }}
96 | className="inline-flex w-full justify-center rounded-md border border-transparent bg-red-600 px-4 py-2 text-base font-medium text-white shadow-sm hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-red-500 focus:ring-offset-2 sm:ml-3 sm:w-auto sm:text-sm"
97 | >
98 | {/* eslint-disable-next-line no-nested-ternary */}
99 | {isDeleting
100 | ? `Deleting ${resultName}...`
101 | : didDelete
102 | ? `Deleted ${resultName}!`
103 | : `Delete ${resultName}`}
104 |
105 | }
106 | >
107 | );
108 | }
109 |
--------------------------------------------------------------------------------
/context/picture-context.jsx:
--------------------------------------------------------------------------------
1 | import { useState, createContext, useContext } from "react";
2 | import { pictureTypes } from "../utils/picture-utils";
3 | import { supabase, generateUrlSuffix, dateToString } from "../utils/supabase";
4 |
5 | export const PicturesContext = createContext();
6 |
7 | export function PicturesContextProvider(props) {
8 | const [pictures, setPictures] = useState({}); // {userId: {date: {type: url}}}
9 |
10 | const getPicture = async (userId, config, refresh = false) => {
11 | console.log("requesting picture", userId, config);
12 |
13 | let { date } = config;
14 | const dates = Array.isArray(date) ? date : [date];
15 | const dateStrings = dates.map((date) => dateToString(date));
16 |
17 | const { options, types = pictureTypes } = config;
18 |
19 | let picturesList = [];
20 | if (options) {
21 | const { data: list, error: listError } = await supabase.storage
22 | .from("picture")
23 | .list(userId, options);
24 | if (listError) {
25 | console.error(listError);
26 | } else {
27 | picturesList = list;
28 | }
29 | } else if (dateStrings?.length > 0) {
30 | await Promise.all(
31 | dateStrings.map(async (dateString) => {
32 | const { data: list, error: listError } = await supabase.storage
33 | .from("picture")
34 | .list(userId, { search: dateString });
35 | if (listError) {
36 | console.error(listError);
37 | } else {
38 | picturesList = picturesList.concat(...list);
39 | }
40 | })
41 | );
42 | }
43 |
44 | picturesList.forEach((picture) => {
45 | const [dateString, type] = picture.name.split(".")[0].split("_");
46 | picture.dateString = dateString;
47 | picture.type = type;
48 | });
49 | console.log("picturesList", picturesList, pictures);
50 | console.log(types);
51 | picturesList = picturesList
52 | .filter((picture) => types.includes(picture.type))
53 | .filter(
54 | (picture) =>
55 | refresh || !pictures[userId]?.[picture.dateString]?.[picture.type]
56 | );
57 |
58 | console.log("picturesList", picturesList);
59 |
60 | const newPictures = {
61 | ...pictures,
62 | };
63 |
64 | if (refresh) {
65 | if (dateStrings) {
66 | dateStrings.forEach((dateString) => {
67 | types.forEach((type) => {
68 | delete newPictures[userId]?.[dateString]?.[type];
69 | });
70 | });
71 | } else {
72 | }
73 | }
74 |
75 | if (picturesList.length > 0) {
76 | const picturePaths = picturesList.map(
77 | (picture) => `${userId}/${picture.name}`
78 | );
79 | const { data: pictureUrls, error } = await supabase.storage
80 | .from("picture")
81 | .createSignedUrls(picturePaths, 60);
82 | if (error) {
83 | console.error(error);
84 | } else {
85 | console.log("pictureUrls", pictureUrls);
86 |
87 | pictureUrls.forEach(({ path, signedURL }) => {
88 | const [id, name] = path.split("/");
89 | const [dateString, type] = name.split(".")[0].split("_");
90 | newPictures[id] = newPictures[id] || {};
91 | newPictures[id][dateString] = newPictures[id][dateString] || {};
92 | newPictures[id][dateString][type] = signedURL;
93 |
94 | const picture = picturesList.find((picture) => picture.name == name);
95 | if (picture) {
96 | newPictures[id][dateString][type] +=
97 | "&" + generateUrlSuffix(picture);
98 | }
99 | });
100 | console.log("newPictures", newPictures);
101 | setPictures(newPictures);
102 | }
103 | } else {
104 | newPictures[userId] = newPictures[userId] || {};
105 | dateStrings.forEach((dateString) => {
106 | newPictures[userId][dateString] = newPictures[userId][dateString] || {};
107 | });
108 | console.log("newPictures", newPictures);
109 | setPictures(newPictures);
110 | }
111 | };
112 |
113 | const value = { pictures, getPicture };
114 |
115 | return ;
116 | }
117 |
118 | export function usePictures() {
119 | const context = useContext(PicturesContext);
120 | return context;
121 | }
122 |
--------------------------------------------------------------------------------
/pages/dashboard/my-coaches.jsx:
--------------------------------------------------------------------------------
1 | import { getDashboardLayout } from "../../components/layouts/DashboardLayout";
2 | import DeleteSubscriptionModal from "../../components/dashboard/modal/DeleteSubscriptionModal";
3 | import Table from "../../components/Table";
4 | import { useUser } from "../../context/user-context";
5 | import { formatDollars } from "../../utils/subscription-utils";
6 | import MyLink from "../../components/MyLink";
7 | import { useCoachPictures } from "../../context/coach-picture-context";
8 | import { useEffect, useState } from "react";
9 |
10 | const filterTypes = [
11 | {
12 | name: "Redeemed?",
13 | query: "redeemed",
14 | column: "redeemed",
15 | radios: [
16 | { value: true, label: "yes", defaultChecked: false },
17 | { value: false, label: "no", defaultChecked: false },
18 | { value: null, label: "either", defaultChecked: true },
19 | ],
20 | },
21 | ];
22 |
23 | const orderTypes = [
24 | {
25 | label: "Date Redeemed",
26 | query: "date-redeemed",
27 | value: ["redeemed_at", { ascending: false }],
28 | current: false,
29 | },
30 | {
31 | label: "Coach Email",
32 | query: "coach-email",
33 | value: ["coach", { ascending: true }],
34 | current: false,
35 | },
36 | {
37 | label: "Price",
38 | query: "price",
39 | value: ["price", { ascending: false }],
40 | current: false,
41 | },
42 | ];
43 |
44 | export default function MyCoaches() {
45 | const { user, stripeLinks } = useUser();
46 | const { coachPictures, getCoachPicture } = useCoachPictures();
47 |
48 | const [baseFilter, setBaseFilter] = useState();
49 | useEffect(() => {
50 | if (user) {
51 | setBaseFilter({ client: user.id });
52 | }
53 | }, [user]);
54 |
55 | const [coaches, setCoaches] = useState();
56 | useEffect(() => {
57 | if (coaches) {
58 | coaches.forEach(({ coach }) => getCoachPicture(coach));
59 | }
60 | }, [coaches]);
61 |
62 | return (
63 | <>
64 |
71 |
75 | Manage Subscriptions
76 |
77 |
78 | }
79 | filterTypes={filterTypes}
80 | orderTypes={orderTypes}
81 | tableName="subscription"
82 | baseFilter={baseFilter}
83 | resultName="coach"
84 | resultNamePlural="coaches"
85 | title="My Coaches"
86 | DeleteResultModal={DeleteSubscriptionModal}
87 | resultMap={(result) => {
88 | const coachPicture = coachPictures[result.coach]?.url;
89 | return [
90 | {
91 | title: "coach",
92 | value: result.coach_email,
93 | },
94 | {
95 | title: "price",
96 | value: `${formatDollars(result.price, false)}/month`,
97 | },
98 | result.redeemed && {
99 | title: "redeemed at",
100 | value: new Date(result.redeemed_at).toLocaleString(),
101 | },
102 | result.redeemed && {
103 | title: "active?",
104 | value: result.is_active ? "yes" : "no",
105 | },
106 | result.redeemed && {
107 | title: "cancelled?",
108 | value: result.is_cancelled ? "yes" : "no",
109 | },
110 | coachPicture && {
111 | jsx: (
112 |
117 | ),
118 | },
119 | ].filter(Boolean);
120 | }}
121 | resultsListener={(results) => {
122 | console.log("resultsListener", results);
123 | setCoaches(results);
124 | }}
125 | >
126 | >
127 | );
128 | }
129 |
130 | MyCoaches.getLayout = getDashboardLayout;
131 |
--------------------------------------------------------------------------------
/components/ExerciseTypeVideo.jsx:
--------------------------------------------------------------------------------
1 | import { useEffect, useState, useRef } from "react";
2 | import LazyVideo from "./LazyVideo";
3 | import { useExerciseVideos } from "../context/exercise-videos-context";
4 | import { isMobile, isDesktop } from "react-device-detect";
5 | import LazyImage from "./LazyImage";
6 |
7 | function classNames(...classes) {
8 | return classes.filter(Boolean).join(" ");
9 | }
10 |
11 | const keysToDelete = ["width", "height", "play"];
12 |
13 | export default function ExerciseTypeVideo(
14 | props = { exerciseTypeId: undefined, play: null, fetchVideo: true }
15 | ) {
16 | const {
17 | exerciseTypeId,
18 | play,
19 | width = 100,
20 | height = 100,
21 | fetchVideo,
22 | className = "min-h-[100px] min-w-[100px]",
23 | } = props;
24 | const propsSubset = Object.assign({}, props);
25 | keysToDelete.forEach((key) => delete propsSubset[key]);
26 |
27 | const sizeClassname = `w-[${width}px] h-[${height}px]`;
28 |
29 | const { getExerciseVideo, exerciseVideos } = useExerciseVideos();
30 | useEffect(() => {
31 | if (exerciseTypeId && fetchVideo) {
32 | getExerciseVideo(exerciseTypeId);
33 | }
34 | }, [exerciseTypeId]);
35 |
36 | const [showVideo, setShowVideo] = useState(false);
37 |
38 | const videoRef = useRef(null);
39 | useEffect(() => {
40 | const { current: video } = videoRef;
41 | if (video) {
42 | if (showVideo) {
43 | video.play();
44 | } else {
45 | video.pause();
46 | }
47 | }
48 | }, [showVideo]);
49 |
50 | useEffect(() => {
51 | if (play !== null) {
52 | setShowVideo(play);
53 | }
54 | }, [play]);
55 |
56 | const [shouldShowVideo, setShouldShowVideo] = useState(false);
57 | const [hasPlayed, setHasPlayed] = useState(false);
58 | const [hasLoadedData, setHasLoadedData] = useState(false);
59 | const [isSuspended, setIsSuspended] = useState(false);
60 | useEffect(() => {
61 | setShouldShowVideo(hasPlayed && showVideo && !isSuspended);
62 | }, [showVideo, hasPlayed, isSuspended]);
63 |
64 | return (
65 | {
67 | if (isDesktop) {
68 | setShowVideo(true);
69 | }
70 | }}
71 | onMouseLeave={() => {
72 | if (isDesktop) {
73 | setShowVideo(false);
74 | }
75 | }}
76 | onClick={(e) => {
77 | const { current: video } = videoRef;
78 | if (isMobile) {
79 | console.log("VIDEO TOGGLE", video, video.readyState);
80 | if (video) {
81 | if (video.readyState === 0) {
82 | setShowVideo(true);
83 | } else if (video.readyState <= 3) {
84 | video.play();
85 | } else {
86 | setShowVideo(!showVideo);
87 | }
88 | }
89 | }
90 | }}
91 | className={sizeClassname}
92 | >
93 |
{
95 | setIsSuspended(true);
96 | document.addEventListener(
97 | "click",
98 | async () => {
99 | await e.target.play();
100 | },
101 | {
102 | once: true,
103 | }
104 | );
105 | }}
106 | onPlay={() => {
107 | console.log("onPlay");
108 | setHasPlayed(true);
109 | setIsSuspended(false);
110 | }}
111 | onCanPlayThrough={(e) => {
112 | if (!hasLoadedData) {
113 | console.log("setHasLoadedData", e.target);
114 | e.target.play();
115 | setHasLoadedData(true);
116 | }
117 | }}
118 | onPause={() => {
119 | setHasPlayed(false);
120 | }}
121 | width={width}
122 | height={height}
123 | src={exerciseVideos?.[exerciseTypeId]?.url}
124 | poster={exerciseVideos?.[exerciseTypeId]?.thumbnailUrl}
125 | autoPlay={false}
126 | muted={true}
127 | loop={true}
128 | className={classNames(
129 | "absolute z-10 overflow-hidden rounded-lg",
130 | className,
131 | sizeClassname,
132 | shouldShowVideo ? "" : "hidden"
133 | )}
134 | playsInline={true}
135 | controls={false}
136 | ref={videoRef}
137 | >
138 |
139 | {exerciseVideos?.[exerciseTypeId]?.thumbnailUrl && (
140 |
147 | )}
148 |
149 |
150 | );
151 | }
152 |
--------------------------------------------------------------------------------
/components/layouts/DashboardLayout.jsx:
--------------------------------------------------------------------------------
1 | import { useRouter } from "next/router";
2 | import { useEffect } from "react";
3 | import Head from "next/head";
4 | import {
5 | BellIcon,
6 | UserCircleIcon,
7 | UserGroupIcon,
8 | ClipboardListIcon,
9 | CameraIcon,
10 | ClipboardCheckIcon,
11 | ClipboardIcon,
12 | ChartBarIcon,
13 | HeartIcon,
14 | ScaleIcon,
15 | ClipboardCopyIcon,
16 | TableIcon,
17 | TemplateIcon,
18 | ViewGridIcon,
19 | } from "@heroicons/react/outline";
20 | import MyLink from "../MyLink";
21 | import { useUser } from "../../context/user-context";
22 |
23 | const navigation = [
24 | {
25 | name: "General",
26 | href: "/dashboard",
27 | icon: UserCircleIcon,
28 | },
29 | {
30 | name: "All Users",
31 | href: "/dashboard/all-users",
32 | icon: UserGroupIcon,
33 | isAdmin: true,
34 | },
35 | {
36 | name: "My Coaches",
37 | href: "/dashboard/my-coaches",
38 | icon: ClipboardIcon,
39 | },
40 | {
41 | name: "My Clients",
42 | href: "/dashboard/my-clients",
43 | icon: UserGroupIcon,
44 | canCoach: true,
45 | },
46 | {
47 | name: "Exercise Types",
48 | href: "/dashboard/exercise-types",
49 | icon: ClipboardListIcon,
50 | },
51 | {
52 | name: "Diary",
53 | href: "/dashboard/diary",
54 | icon: ClipboardCopyIcon,
55 | },
56 | {
57 | name: "Exercises",
58 | href: "/dashboard/exercises",
59 | icon: ClipboardCheckIcon,
60 | },
61 | {
62 | name: "Blocks",
63 | href: "/dashboard/blocks",
64 | icon: TemplateIcon,
65 | },
66 | {
67 | name: "Progress",
68 | href: "/dashboard/progress",
69 | icon: ChartBarIcon,
70 | },
71 | {
72 | name: "Bodyweight",
73 | href: "/dashboard/bodyweight",
74 | icon: ScaleIcon,
75 | },
76 | {
77 | name: "Pictures",
78 | href: "/dashboard/pictures",
79 | icon: CameraIcon,
80 | },
81 | {
82 | name: "Notifications",
83 | href: "/dashboard/notifications",
84 | icon: BellIcon,
85 | },
86 | ];
87 |
88 | function classNames(...classes) {
89 | return classes.filter(Boolean).join(" ");
90 | }
91 |
92 | export default function DashboardLayout({ children }) {
93 | const router = useRouter();
94 | const { isLoading, user, isAdmin } = useUser();
95 |
96 | useEffect(() => {
97 | if (router.isReady && !isLoading && !user) {
98 | router.replace(
99 | {
100 | pathname: "/sign-in",
101 | query: { ...router.query, ...{ redirect_pathname: router.pathname } },
102 | },
103 | "/sign-in",
104 | {
105 | shallow: true,
106 | }
107 | );
108 | }
109 | }, [isLoading, user, router.isReady]);
110 |
111 | return (
112 | !isLoading &&
113 | user && (
114 | <>
115 |
116 | Dashboard - Repsetter
117 |
118 |
119 |
120 |
121 | {navigation.map((item) => {
122 | const current = router.route === item.href;
123 | return (
124 | (!item.isAdmin || isAdmin) &&
125 | (!item.canCoach || user.can_coach) && (
126 |
137 |
146 | {item.name}
147 |
148 | )
149 | );
150 | })}
151 |
152 |
153 |
154 |
157 |
158 | >
159 | )
160 | );
161 | }
162 |
163 | export function getDashboardLayout(page) {
164 | return {page} ;
165 | }
166 |
--------------------------------------------------------------------------------
/context/wii-balance-board-context.jsx:
--------------------------------------------------------------------------------
1 | import { useEffect, useState, createContext, useContext } from "react";
2 | import WiiBalanceBoard from "../utils/wii-balance-board/WiiBalanceBoard";
3 |
4 | export const WiiBalanceBoardContext = createContext();
5 |
6 | export function WiiBalanceBoardContextProvider(props) {
7 | const [wiiBalanceBoard, setWiiBalanceBoard] = useState();
8 |
9 | const [canConnectToWiiBalanceBoard, setCanConnectToWiiBalanceBoard] =
10 | useState(false);
11 | useEffect(() => {
12 | setCanConnectToWiiBalanceBoard(navigator?.hid);
13 | }, []);
14 |
15 | const connectToWiiBalanceBoard = async () => {
16 | if (canConnectToWiiBalanceBoard && !wiiBalanceBoard) {
17 | try {
18 | const devices = await navigator.hid.requestDevice({
19 | filters: [{ vendorId: 0x057e }],
20 | });
21 |
22 | const device = devices[0];
23 | if (device) {
24 | const wiiBalanceBoard = new WiiBalanceBoard(device);
25 | wiiBalanceBoard.device.addEventListener(
26 | "inputreport",
27 | async () => {
28 | await wiiBalanceBoard.setLed(0, true);
29 | },
30 | { once: true }
31 | );
32 | window.wiiBalanceBoard = wiiBalanceBoard;
33 | wiiBalanceBoard._isAButtonDown = false;
34 | wiiBalanceBoard.BtnListener = (buttons) => {
35 | wiiBalanceBoardEventListeners.buttons?.forEach((callback) =>
36 | callback(buttons)
37 | );
38 |
39 | if (wiiBalanceBoard._isAButtonDown !== buttons.A) {
40 | wiiBalanceBoard._isAButtonDown = buttons.A;
41 | const eventType = buttons.A ? "buttondown" : "buttonup";
42 | console.log(eventType);
43 | wiiBalanceBoardEventListeners[eventType]?.forEach((callback) =>
44 | callback()
45 | );
46 | }
47 | };
48 | wiiBalanceBoard.WeightListener = (weights) => {
49 | //console.log("wiiBalanceBoard weights", weights);
50 | //const {BOTTOM_LEFT, BOTTOM_RIGHT, TOP_LEFT, TOP_RIGHT, total} = weights
51 | wiiBalanceBoardEventListeners.weights?.forEach((callback) =>
52 | callback(weights)
53 | );
54 | };
55 | console.log(`HID: ${wiiBalanceBoard.productName}`);
56 | setWiiBalanceBoard(wiiBalanceBoard);
57 | }
58 | } catch (error) {
59 | console.log("An error occurred.", error);
60 | }
61 | }
62 | };
63 |
64 | const [wiiBalanceBoardEventListeners, setWiiBalanceBoardEventListeners] =
65 | useState({ button: [], buttondown: [], buttonup: [], weights: [] });
66 |
67 | const addWiiBalanceBoardEventListener = (type, callback) => {
68 | if (
69 | type in wiiBalanceBoardEventListeners &&
70 | callback &&
71 | !wiiBalanceBoardEventListeners[type].includes(callback)
72 | ) {
73 | const newWiiBalanceBoardEventListeners = {
74 | ...wiiBalanceBoardEventListeners,
75 | };
76 | newWiiBalanceBoardEventListeners[type].push(callback);
77 | setWiiBalanceBoardEventListeners(newWiiBalanceBoardEventListeners);
78 | }
79 | };
80 | const removeWiiBalanceBoardEventListener = (type, callback) => {
81 | if (
82 | type in wiiBalanceBoardEventListeners &&
83 | callback &&
84 | wiiBalanceBoardEventListeners[type].includes(callback)
85 | ) {
86 | const newWiiBalanceBoardEventListeners = {
87 | ...wiiBalanceBoardEventListeners,
88 | };
89 | const index = wiiBalanceBoardEventListeners[type].indexOf(callback);
90 | newWiiBalanceBoardEventListeners[type].splice(index, 1);
91 | setWiiBalanceBoardEventListeners(newWiiBalanceBoardEventListeners);
92 | }
93 | };
94 | useEffect(() => {
95 | console.log(
96 | "wiiBalanceBoardEventListeners",
97 | wiiBalanceBoardEventListeners.weights
98 | );
99 | }, [wiiBalanceBoardEventListeners]);
100 |
101 | const openWiiBalanceBoardData = async () => {
102 | if (wiiBalanceBoard?.device && !wiiBalanceBoard.device.opened) {
103 | await wiiBalanceBoard?.device?.open();
104 | await wiiBalanceBoard?.setLed(0, true);
105 | }
106 | };
107 | const closeWiiBalanceBoardData = async () => {
108 | if (wiiBalanceBoard?.device?.opened) {
109 | await wiiBalanceBoard?.setLed(0, false);
110 | await wiiBalanceBoard?.device?.close();
111 | }
112 | };
113 |
114 | const value = {
115 | wiiBalanceBoard,
116 | canConnectToWiiBalanceBoard,
117 | addWiiBalanceBoardEventListener,
118 | removeWiiBalanceBoardEventListener,
119 | connectToWiiBalanceBoard,
120 | openWiiBalanceBoardData,
121 | closeWiiBalanceBoardData,
122 | };
123 |
124 | return ;
125 | }
126 |
127 | export function useWiiBalanceBoard() {
128 | const context = useContext(WiiBalanceBoardContext);
129 | return context;
130 | }
131 |
--------------------------------------------------------------------------------
/context/user-context.jsx:
--------------------------------------------------------------------------------
1 | import { useEffect, useState, createContext, useContext } from "react";
2 | import { useRouter } from "next/router";
3 | import {
4 | supabase,
5 | getUserProfile,
6 | supabaseAuthHeader,
7 | isUserAdmin,
8 | } from "../utils/supabase";
9 | export const UserContext = createContext();
10 |
11 | export function UserContextProvider(props) {
12 | const [session, setSession] = useState(null);
13 | const [user, setUser] = useState(supabase.auth.user());
14 | const [isLoading, setIsLoading] = useState(true);
15 | const [didDeleteAccount, setDidDeleteAccount] = useState(false);
16 | const [isAdmin, setIsAdmin] = useState(false);
17 | const [baseFetchHeaders, setBaseFetchHeaders] = useState({});
18 | const [stripeLinks, setStripeLinks] = useState({});
19 |
20 | useEffect(() => {
21 | if (session?.access_token) {
22 | setBaseFetchHeaders({ [supabaseAuthHeader]: session.access_token });
23 | }
24 | }, [session]);
25 |
26 | useEffect(() => {
27 | if (session?.access_token) {
28 | setStripeLinks({
29 | onboarding: `/api/account/stripe-onboarding?access_token=${session.access_token}`,
30 | dashboard: `/api/account/stripe-dashboard?access_token=${session.access_token}`,
31 | customerPortal: `/api/account/stripe-customer-portal?access_token=${session.access_token}`,
32 | });
33 | }
34 | }, [session]);
35 |
36 | const fetchWithAccessToken = (url, options) =>
37 | fetch(url, {
38 | ...(options || {}),
39 | headers: { ...(options?.headers || {}), ...baseFetchHeaders },
40 | });
41 |
42 | const updateUserProfile = async () => {
43 | const user = supabase.auth.user();
44 | if (user) {
45 | const profile = await getUserProfile(user);
46 | setUser({
47 | ...user,
48 | ...profile,
49 | });
50 | } else {
51 | setUser(null);
52 | }
53 | setIsLoading(false);
54 | };
55 |
56 | useEffect(() => {
57 | const session = supabase.auth.session();
58 | setSession(session);
59 | console.log("session", session);
60 |
61 | updateUserProfile();
62 |
63 | const { data: authListener } = supabase.auth.onAuthStateChange(
64 | async (event, session) => {
65 | console.log(event, session);
66 |
67 | setSession(session);
68 | switch (event) {
69 | case "SIGNED_IN":
70 | await updateUserProfile();
71 | break;
72 | case "SIGNED_OUT":
73 | setUser(null);
74 | break;
75 | case "TOKEN_REFRESHED":
76 | await updateUserProfile();
77 | break;
78 | case "USER_UPDATED":
79 | break;
80 | case "USER_DELETED":
81 | setUser(null);
82 | break;
83 | default:
84 | console.log(`uncaught event "${event}"`);
85 | break;
86 | }
87 | }
88 | );
89 |
90 | return () => {
91 | authListener?.unsubscribe();
92 | };
93 | }, []);
94 |
95 | const router = useRouter();
96 | useEffect(() => {
97 | if (session) {
98 | const { expires_at } = session;
99 | const expirationDate = new Date(expires_at * 1000);
100 | const currentTime = new Date();
101 | if (expirationDate.getTime() < currentTime.getTime()) {
102 | router.reload();
103 | }
104 | }
105 | }, [session]);
106 |
107 | const signOut = async () => {
108 | await supabase.auth.signOut();
109 | setUser(null);
110 | };
111 |
112 | // eslint-disable-next-line consistent-return
113 | useEffect(() => {
114 | if (user) {
115 | console.log("subscribing to user updates");
116 | const subscription = supabase
117 | .from(`profile:id=eq.${user.id}`)
118 | .on("UPDATE", (payload) => {
119 | console.log("updated profile");
120 | setUser({ ...user, ...payload.new });
121 | })
122 | .on("DELETE", () => {
123 | console.log("deleted account");
124 | signOut();
125 | })
126 | .subscribe();
127 | return () => {
128 | console.log("unsubscribing to user updates");
129 | supabase.removeSubscription(subscription);
130 | };
131 | }
132 | }, [user]);
133 |
134 | useEffect(() => {
135 | if (user) {
136 | setIsAdmin(isUserAdmin(user));
137 | }
138 | }, [user]);
139 |
140 | const deleteAccount = async () => {
141 | await fetchWithAccessToken("/api/account/delete-account");
142 | signOut();
143 | setDidDeleteAccount(true);
144 | };
145 |
146 | // eslint-disable-next-line react/jsx-no-constructed-context-values
147 | const value = {
148 | user,
149 | session,
150 | signOut,
151 | deleteAccount,
152 | isLoading,
153 | didDeleteAccount,
154 |
155 | fetchWithAccessToken,
156 | stripeLinks,
157 |
158 | isAdmin,
159 | };
160 |
161 | return ;
162 | }
163 |
164 | export function useUser() {
165 | const context = useContext(UserContext);
166 | return context;
167 | }
168 |
--------------------------------------------------------------------------------
/pages/api/subscription/create-subscription.js:
--------------------------------------------------------------------------------
1 | /* eslint-disable consistent-return */
2 | import Stripe from "stripe";
3 | import {
4 | getSupabaseService,
5 | getUserProfile,
6 | getUserByAccessToken,
7 | } from "../../../utils/supabase";
8 |
9 | import {
10 | maxNumberOfUnredeemedSubscriptionsPerCoach,
11 | updateNumberOfUnredeemedSubscriptions,
12 | } from "../../../utils/subscription-utils";
13 |
14 | export default async function handler(req, res) {
15 | const sendError = (error) =>
16 | res.status(200).json({
17 | status: {
18 | type: "failed",
19 | title: "Failed to Create Subscription",
20 | ...error,
21 | },
22 | });
23 |
24 | const supabase = getSupabaseService();
25 | const { user } = await getUserByAccessToken(supabase, req);
26 | if (!user) {
27 | return sendError({ message: "You are not signed in" });
28 | }
29 |
30 | const profile = await getUserProfile(user, supabase);
31 | if (!profile) {
32 | return sendError({ message: "profile not found" });
33 | }
34 |
35 | if (!profile.can_coach) {
36 | return sendError({
37 | message: "you haven't set up your Stripe Account yet",
38 | });
39 | }
40 |
41 | const { count: number_of_unredeemed_subscriptions } = await supabase
42 | .from("subscription")
43 | .select("*", { count: "exact", head: true })
44 | .match({ coach: profile.id, redeemed: false });
45 | console.log(
46 | "number_of_unredeemed_subscriptions",
47 | number_of_unredeemed_subscriptions
48 | );
49 |
50 | if (
51 | number_of_unredeemed_subscriptions >=
52 | maxNumberOfUnredeemedSubscriptionsPerCoach
53 | ) {
54 | return sendError({
55 | message:
56 | "You've exceeded the number of unredeemed subscriptions. You must wait for any existing ones to be redeemed or delete one to create a new one.",
57 | });
58 | }
59 |
60 | console.log("user", user);
61 | console.log("profile", profile);
62 |
63 | if (!("subscriptionPrice" in req.body)) {
64 | return sendError({
65 | message: 'missing "subscriptionPrice" parameter',
66 | });
67 | }
68 |
69 | let { subscriptionPrice } = req.body;
70 | if (isNaN(subscriptionPrice)) {
71 | return sendError({
72 | message: "invalid subscription price",
73 | });
74 | }
75 |
76 | subscriptionPrice = Math.floor(Number(subscriptionPrice));
77 | let priceObject;
78 |
79 | const stripe = Stripe(process.env.STRIPE_SECRET_KEY);
80 |
81 | const { data: picturesList, error: listPicturesError } =
82 | await supabase.storage
83 | .from("coach-picture")
84 | .list(profile.id, { limit: 1, search: "image" });
85 | if (listPicturesError) {
86 | console.error(listPicturesError);
87 | } else {
88 | console.log("picturesList", picturesList);
89 |
90 | let pictureUrl;
91 | if (picturesList.length > 0) {
92 | const { publicURL, error: getPictureUrlError } = await supabase.storage
93 | .from("coach-picture")
94 | .getPublicUrl(
95 | `${profile.id}/image.jpg?t=${new Date(
96 | picturesList[0].updated_at
97 | ).getTime()}`
98 | );
99 | if (getPictureUrlError) {
100 | console.error(getPictureUrlError);
101 | } else {
102 | pictureUrl = publicURL;
103 | }
104 | }
105 |
106 | const product = await stripe.products.retrieve(profile.product_id);
107 | console.log("product", product);
108 |
109 | let shouldUpdateImages = false;
110 | if (product.images.length === 1 && pictureUrl) {
111 | shouldUpdateImages = true;
112 | }
113 | if (
114 | product.images.length > 1 &&
115 | (!pictureUrl || !product.images.includes(pictureUrl))
116 | ) {
117 | shouldUpdateImages = true;
118 | }
119 |
120 | if (shouldUpdateImages) {
121 | let images = ["https://www.repsetter.com/images/logo.png"];
122 | if (pictureUrl) {
123 | images.unshift(pictureUrl);
124 | }
125 | console.log("new images", images);
126 | await stripe.products.update(product.id, { images });
127 | }
128 | }
129 |
130 | priceObject = await stripe.prices.create({
131 | unit_amount: subscriptionPrice * 100,
132 | currency: "usd",
133 | recurring: { interval: "month" },
134 | product: profile.product_id,
135 | });
136 |
137 | console.log("priceObject", priceObject);
138 |
139 | const { data: subscriptions, error: insertSubscriptionError } = await supabase
140 | .from("subscription")
141 | .insert([
142 | {
143 | coach: profile.id,
144 | coach_email: profile.email,
145 | price: subscriptionPrice,
146 | price_id: priceObject?.id || profile.default_price_id,
147 | },
148 | ]);
149 |
150 | if (insertSubscriptionError) {
151 | return sendError({
152 | message: insertSubscriptionError.message,
153 | });
154 | }
155 |
156 | const [subscription] = subscriptions;
157 | console.log("subscription", subscription);
158 |
159 | if (!subscription) {
160 | return sendError({
161 | message: "no subscription was created",
162 | });
163 | }
164 |
165 | await updateNumberOfUnredeemedSubscriptions(profile, supabase);
166 |
167 | res.status(200).json({
168 | status: {
169 | type: "succeeded",
170 | title: "Successfully Created Subscription",
171 | },
172 | subscription: subscription.id,
173 | });
174 | }
175 |
--------------------------------------------------------------------------------
/pages/dashboard/all-users.jsx:
--------------------------------------------------------------------------------
1 | import { useEffect, useState } from "react";
2 | import { useRouter } from "next/router";
3 | import { useUser } from "../../context/user-context";
4 | import { getDashboardLayout } from "../../components/layouts/DashboardLayout";
5 | import DeleteUserModal from "../../components/dashboard/modal/DeleteUserModal";
6 | import Table from "../../components/Table";
7 | import { useClient } from "../../context/client-context";
8 | import { useCoachPictures } from "../../context/coach-picture-context";
9 |
10 | const filterTypes = [
11 | {
12 | name: "Has Completed Onboarding?",
13 | query: "has-completed-onboarding",
14 | column: "has_completed_onboarding",
15 | radios: [
16 | { value: true, label: "yes", defaultChecked: false },
17 | { value: false, label: "no", defaultChecked: false },
18 | { value: null, label: "either", defaultChecked: true },
19 | ],
20 | },
21 | {
22 | name: "Can Coach?",
23 | query: "can-coach",
24 | column: "can_coach",
25 | radios: [
26 | { value: true, label: "yes", defaultChecked: false },
27 | { value: false, label: "no", defaultChecked: false },
28 | { value: null, label: "either", defaultChecked: true },
29 | ],
30 | },
31 | ];
32 |
33 | const orderTypes = [
34 | {
35 | label: "Date Joined",
36 | query: "date-joined",
37 | value: ["created_at", { ascending: false }],
38 | current: false,
39 | },
40 | {
41 | label: "Email",
42 | query: "email",
43 | value: ["email", { ascending: true }],
44 | current: false,
45 | },
46 | ];
47 |
48 | export default function AllUsers() {
49 | const router = useRouter();
50 | const { isAdmin, fetchWithAccessToken } = useUser();
51 | const { setInitialClientEmail, setOverrideInitialClientEmail } = useClient();
52 |
53 | const [baseFilter, setBaseFilter] = useState({});
54 |
55 | const { coachPictures, getCoachPicture } = useCoachPictures();
56 | const [users, setUsers] = useState();
57 | useEffect(() => {
58 | if (users) {
59 | //users.forEach(({ id }) => getCoachPicture(id));
60 | getCoachPicture(users.map(({ id }) => id));
61 | }
62 | }, [users]);
63 |
64 | useEffect(() => {
65 | if (router.isReady && !isAdmin) {
66 | console.log("redirect to /dashboard");
67 | router.replace("/dashboard", undefined, {
68 | shallow: true,
69 | });
70 | }
71 | }, [router.isReady]);
72 |
73 | return (
74 | isAdmin && (
75 | <>
76 | [
87 | {
88 | title: "email",
89 | value: result.email,
90 | },
91 | {
92 | title: "completed onboarding?",
93 | value: result.has_completed_onboarding ? "yes" : "no",
94 | },
95 | {
96 | title: "can coach?",
97 | value: result.can_coach ? "yes" : "no",
98 | },
99 | {
100 | title: "joined",
101 | value: new Date(result.created_at).toLocaleString(),
102 | },
103 | {
104 | jsx: (
105 | {
107 | setInitialClientEmail(result.email);
108 | setOverrideInitialClientEmail(true);
109 | }}
110 | className="inline-flex items-center rounded-md border border-gray-300 bg-white px-2.5 py-1.5 text-sm font-medium leading-4 text-gray-700 shadow-sm hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-30"
111 | >
112 | View user
113 |
114 | ),
115 | },
116 | {
117 | jsx: (
118 | {
120 | const response = await fetchWithAccessToken(
121 | `/api/account/check-stripe?userId=${result.id}`
122 | );
123 | const json = await response.json();
124 | console.log("check-stripe response", json);
125 | }}
126 | className="inline-flex items-center rounded-md border border-gray-300 bg-white px-2.5 py-1.5 text-sm font-medium leading-4 text-gray-700 shadow-sm hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-30"
127 | >
128 | Update user
129 |
130 | ),
131 | },
132 | coachPictures?.[result.id]?.url && {
133 | jsx: (
134 |
141 | ),
142 | },
143 | ]}
144 | >
145 | >
146 | )
147 | );
148 | }
149 |
150 | AllUsers.getLayout = getDashboardLayout;
151 |
--------------------------------------------------------------------------------
/components/Modal.jsx:
--------------------------------------------------------------------------------
1 | /* eslint-disable react/destructuring-assignment */
2 | import { Fragment } from "react";
3 | import { Dialog, Transition } from "@headlessui/react";
4 | import { XIcon, ExclamationIcon } from "@heroicons/react/outline";
5 |
6 | function classNames(...classes) {
7 | return classes.filter(Boolean).join(" ");
8 | }
9 |
10 | const colorPalletes = {
11 | red: {
12 | "bg-100": "bg-red-100",
13 | "text-600": "text-red-600",
14 | "bg-600": "bg-red-600",
15 | "focus:ring-500": "focus:ring-red-500",
16 | "hover:bg-700": "hover:bg-red-700",
17 | },
18 | indigo: {
19 | "bg-100": "bg-indigo-100",
20 | "text-600": "text-indigo-600",
21 | "bg-600": "bg-indigo-600",
22 | "focus:ring-500": "focus:ring-indigo-500",
23 | "hover:bg-700": "hover:bg-indigo-700",
24 | },
25 | blue: {
26 | "bg-100": "bg-blue-100",
27 | "text-600": "text-blue-600",
28 | "bg-600": "bg-blue-600",
29 | "focus:ring-500": "focus:ring-blue-500",
30 | "hover:bg-700": "hover:bg-blue-700",
31 | },
32 | };
33 |
34 | export default function Modal({
35 | children,
36 | title,
37 | message,
38 | open,
39 | setOpen,
40 | Icon = ExclamationIcon,
41 | color,
42 | Button,
43 | className = "",
44 | }) {
45 | const colorPallete = colorPalletes[color] || colorPalletes.blue;
46 |
47 | return (
48 |
49 |
50 |
59 |
60 |
61 |
62 |
63 |
64 | {/* This element is to trick the browser into centering the modal contents. */}
65 |
69 |
70 |
71 |
80 |
86 |
87 | setOpen(false)}
94 | >
95 | Close
96 |
97 |
98 |
99 |
100 |
106 |
113 |
114 |
115 |
119 | {title}
120 |
121 |
124 |
125 |
126 | {children}
127 |
128 | {Button}
129 | setOpen(false)}
133 | >
134 | Cancel
135 |
136 |
137 |
138 |
139 |
140 |
141 |
142 |
143 | );
144 | }
145 |
--------------------------------------------------------------------------------
/pages/dashboard/bodyweight.jsx:
--------------------------------------------------------------------------------
1 | import { useEffect, useState } from "react";
2 | import { useUser } from "../../context/user-context";
3 | import { getDashboardLayout } from "../../components/layouts/DashboardLayout";
4 | import WeightModal from "../../components/dashboard/modal/WeightModal";
5 | import DeleteWeightModal from "../../components/dashboard/modal/DeleteWeightModal";
6 | import { weightEvents } from "../../utils/weight-utils";
7 | import Table from "../../components/Table";
8 | import { useClient } from "../../context/client-context";
9 | import MyLink from "../../components/MyLink";
10 | import { timeToDate, stringToDate } from "../../utils/supabase";
11 | import { usePictures } from "../../context/picture-context";
12 | import { pictureTypes } from "../../utils/picture-utils";
13 |
14 | const filterTypes = [
15 | {
16 | name: "Weight Event",
17 | query: "weight-event",
18 | column: "event?in",
19 | checkboxes: [
20 | ...weightEvents.map(({ name }, index) => ({
21 | value: name,
22 | label: name,
23 | defaultChecked: false,
24 | })),
25 | ],
26 | },
27 | ];
28 |
29 | const orderTypes = [
30 | {
31 | label: "Date (Newest)",
32 | query: "date-newest",
33 | value: [
34 | ["date", { ascending: false }],
35 | ["time", { ascending: true }],
36 | ],
37 | current: true,
38 | },
39 | {
40 | label: "Date (Oldest)",
41 | query: "date-oldest",
42 | value: [
43 | ["date", { ascending: true }],
44 | ["time", { ascending: true }],
45 | ],
46 | current: false,
47 | },
48 | ];
49 |
50 | export default function Bodyweight() {
51 | const { user } = useUser();
52 |
53 | const { selectedClient, setSelectedDate, selectedClientId, amITheClient } =
54 | useClient();
55 |
56 | const [weights, setWeights] = useState();
57 | const [baseFilter, setBaseFilter] = useState();
58 | useEffect(() => {
59 | if (!selectedClientId) {
60 | return;
61 | }
62 |
63 | console.log("selectedClientId", selectedClientId);
64 |
65 | const newBaseFilter = {};
66 | newBaseFilter.client = selectedClientId;
67 | setBaseFilter(newBaseFilter);
68 | }, [user, selectedClientId]);
69 |
70 | console.log("baseFilter", baseFilter);
71 |
72 | const { pictures, getPicture } = usePictures();
73 | useEffect(() => {
74 | if (weights && selectedClientId) {
75 | getPicture(selectedClientId, {
76 | date: weights.map(({ date }) => stringToDate(date)),
77 | });
78 | }
79 | }, [weights, selectedClientId]);
80 |
81 | return (
82 | <>
83 | {
103 | const todaysPictures = pictures?.[selectedClientId]?.[weight.date];
104 | const pictureItems = todaysPictures
105 | ? pictureTypes
106 | .filter((type) => type in todaysPictures)
107 | .map((type) => ({
108 | jsx: (
109 | <>
110 |
117 | >
118 | ),
119 | }))
120 | : [];
121 | return [
122 | {
123 | title: "date",
124 | value: stringToDate(weight.date).toDateString(),
125 | },
126 | weight.time && {
127 | title: "time",
128 | value: timeToDate(weight.time).toLocaleTimeString([], {
129 | timeStyle: "short",
130 | }),
131 | },
132 | {
133 | title: "weight",
134 | value: `${weight.weight} (${
135 | weight.is_weight_in_kilograms ? "kg" : "lbs"
136 | })`,
137 | },
138 | weight.bodyfat_percentage !== null && {
139 | title: "bodyfat percentage",
140 | value: `${weight.bodyfat_percentage}%`,
141 | },
142 | ...pictureItems,
143 | weight.event && {
144 | title: "event",
145 | value: weight.event,
146 | },
147 | {
148 | jsx: (
149 | {
151 | setSelectedDate(stringToDate(weight.date));
152 | }}
153 | href={`/dashboard/diary?date=${stringToDate(
154 | weight.date
155 | ).toDateString()}${
156 | selectedClient
157 | ? `&client=${selectedClient.client_email}`
158 | : ""
159 | }`}
160 | className="inline-flex items-center rounded-md border border-gray-300 bg-white px-2.5 py-1.5 text-sm font-medium leading-4 text-gray-700 shadow-sm hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-30"
161 | >
162 | Diary
163 |
164 | ),
165 | },
166 | ];
167 | }}
168 | />
169 | >
170 | );
171 | }
172 |
173 | Bodyweight.getLayout = getDashboardLayout;
174 |
--------------------------------------------------------------------------------
/utils/MIBCSMetrics.js:
--------------------------------------------------------------------------------
1 | // https://github.com/limhenry/web-bluetooth-mi-scale/blob/06fe9dce1af4cb12d976ed1100ba9c815b1f0fe6/metrics.js#L3
2 |
3 | export default class Metrics {
4 | constructor(weight, impedance, height, age, sex) {
5 | this.weight = weight;
6 | this.impedance = impedance;
7 | this.height = height;
8 | this.age = age;
9 | this.sex = sex;
10 | }
11 |
12 | getResult = () => {
13 | return [
14 | { name: "BMI", value: this.getBMI() },
15 | { name: "Ideal Weight", value: this.getIdealWeight() },
16 | { name: "Metabolic Age", value: this.getMetabolicAge() },
17 | { name: "Protein Percentage", value: this.getProteinPercentage() },
18 | { name: "LBM Coefficient", value: this.getLBMCoefficient() },
19 | { name: "BMR", value: this.getBMR() },
20 | { name: "Fat", value: this.getFatPercentage() },
21 | { name: "Muscle Mass", value: this.getMuscleMass() },
22 | { name: "Bone Mass", value: this.getBoneMass() },
23 | { name: "Visceral Fat", value: this.getVisceralFat() },
24 | ];
25 | };
26 |
27 | checkValueOverflow = (value, minimum, maximum) => {
28 | if (value < minimum) return minimum;
29 | else if (value > maximum) return maximum;
30 | return value;
31 | };
32 |
33 | getIdealWeight = () => {
34 | if (this.sex == "female") return (this.height - 70) * 0.6;
35 | return (this.height - 80) * 0.7;
36 | };
37 |
38 | getMetabolicAge = () => {
39 | let metabolicAge =
40 | this.height * -0.7471 +
41 | this.weight * 0.9161 +
42 | this.age * 0.4184 +
43 | this.impedance * 0.0517 +
44 | 54.2267;
45 | if (this.sex == "female") {
46 | metabolicAge =
47 | this.height * -1.1165 +
48 | this.weight * 1.5784 +
49 | this.age * 0.4615 +
50 | this.impedance * 0.0415 +
51 | 83.2548;
52 | }
53 | return this.checkValueOverflow(metabolicAge, 15, 80);
54 | };
55 |
56 | getVisceralFat = () => {
57 | let subsubcalc, subcalc, vfal;
58 | if (this.sex === "female") {
59 | if (this.weight > (13 - this.height * 0.5) * -1) {
60 | subsubcalc =
61 | this.height * 1.45 + this.height * 0.1158 * this.height - 120;
62 | subcalc = (this.weight * 500) / subsubcalc;
63 | vfal = subcalc - 6 + this.age * 0.07;
64 | } else {
65 | subcalc = 0.691 + this.height * -0.0024 + this.height * -0.0024;
66 | vfal =
67 | (this.height * 0.027 - subcalc * this.weight) * -1 +
68 | this.age * 0.07 -
69 | this.age;
70 | }
71 | } else if (this.height < this.weight * 1.6) {
72 | subcalc = (this.height * 0.4 - this.height * (this.height * 0.0826)) * -1;
73 | vfal = (this.weight * 305) / (subcalc + 48) - 2.9 + this.age * 0.15;
74 | } else {
75 | subcalc = 0.765 + this.height * -0.0015;
76 | vfal =
77 | (this.height * 0.143 - this.weight * subcalc) * -1 +
78 | this.age * 0.15 -
79 | 5.0;
80 | }
81 | return this.checkValueOverflow(vfal, 1, 50);
82 | };
83 |
84 | getProteinPercentage = () => {
85 | let proteinPercentage = (this.getMuscleMass() / this.weight) * 100;
86 | proteinPercentage -= this.getWaterPercentage();
87 |
88 | return this.checkValueOverflow(proteinPercentage, 5, 32);
89 | };
90 |
91 | getWaterPercentage = () => {
92 | let waterPercentage = (100 - this.getFatPercentage()) * 0.7;
93 | let coefficient = 0.98;
94 | if (waterPercentage <= 50) coefficient = 1.02;
95 | if (waterPercentage * coefficient >= 65) waterPercentage = 75;
96 | return this.checkValueOverflow(waterPercentage * coefficient, 35, 75);
97 | };
98 |
99 | getBMI = () => {
100 | return this.checkValueOverflow(
101 | this.weight / ((this.height / 100) * (this.height / 100)),
102 | 10,
103 | 90
104 | );
105 | };
106 |
107 | getBMR = () => {
108 | let bmr;
109 | if (this.sex === "female") {
110 | bmr = 864.6 + this.weight * 10.2036;
111 | bmr -= this.height * 0.39336;
112 | bmr -= this.age * 6.204;
113 | } else {
114 | bmr = 877.8 + this.weight * 14.916;
115 | bmr -= this.height * 0.726;
116 | bmr -= this.age * 8.976;
117 | }
118 |
119 | if (this.sex === "female" && bmr > 2996) bmr = 5000;
120 | else if (this.sex === "male" && bmr > 2322) bmr = 5000;
121 | return this.checkValueOverflow(bmr, 500, 10000);
122 | };
123 |
124 | getFatPercentage = () => {
125 | let value = 0.8;
126 | if (this.sex === "female" && this.age <= 49) value = 9.25;
127 | else if (this.sex === "female" && this.age > 49) value = 7.25;
128 |
129 | const LBM = this.getLBMCoefficient();
130 | let coefficient = 1.0;
131 |
132 | if (this.sex == "male" && this.weight < 61) coefficient = 0.98;
133 | else if (this.sex == "female" && this.weight > 60) {
134 | if (this.height > 160) {
135 | coefficient *= 1.03;
136 | } else {
137 | coefficient = 0.96;
138 | }
139 | } else if (this.sex == "female" && this.weight < 50) {
140 | if (this.height > 160) {
141 | coefficient *= 1.03;
142 | } else {
143 | coefficient = 1.02;
144 | }
145 | }
146 | let fatPercentage =
147 | (1.0 - ((LBM - value) * coefficient) / this.weight) * 100;
148 |
149 | if (fatPercentage > 63) fatPercentage = 75;
150 | return this.checkValueOverflow(fatPercentage, 5, 75);
151 | };
152 |
153 | getMuscleMass = () => {
154 | let muscleMass =
155 | this.weight -
156 | this.getFatPercentage() * 0.01 * this.weight -
157 | this.getBoneMass();
158 | if (this.sex == "female" && muscleMass >= 84) muscleMass = 120;
159 | else if (this.sex == "male" && muscleMass >= 93.5) muscleMass = 120;
160 | return this.checkValueOverflow(muscleMass, 10, 120);
161 | };
162 |
163 | getBoneMass = () => {
164 | let base = 0.18016894;
165 | if (this.sex == "female") base = 0.245691014;
166 |
167 | let boneMass = (base - this.getLBMCoefficient() * 0.05158) * -1;
168 |
169 | if (boneMass > 2.2) boneMass += 0.1;
170 | else boneMass -= 0.1;
171 |
172 | if (this.sex == "female" && boneMass > 5.1) boneMass = 8;
173 | else if (this.sex == "male" && boneMass > 5.2) boneMass = 8;
174 |
175 | return this.checkValueOverflow(boneMass, 0.5, 8);
176 | };
177 |
178 | getLBMCoefficient = () => {
179 | let lbm = ((this.height * 9.058) / 100) * (this.height / 100);
180 | lbm += this.weight * 0.32 + 12.226;
181 | lbm -= this.impedance * 0.0068;
182 | lbm -= this.age * 0.0542;
183 | return lbm;
184 | };
185 | }
186 |
--------------------------------------------------------------------------------
/components/dashboard/modal/DeleteBlockModal.jsx:
--------------------------------------------------------------------------------
1 | /* eslint-disable react/destructuring-assignment */
2 | import { useState, useEffect } from "react";
3 | import Modal from "../../Modal";
4 | import { supabase } from "../../../utils/supabase";
5 |
6 | export default function DeleteBlockModal(props) {
7 | const {
8 | open,
9 | setOpen,
10 | selectedResult: selectedBlock,
11 | selectedResults: selectedBlocks,
12 | setSelectedResult: setSelectedBlock,
13 | setSelectedResults: setSelectedBlocks,
14 | setDeleteResultStatus: setDeleteBlockStatus,
15 | setShowDeleteResultNotification: setShowDeleteBlockNotification,
16 | } = props;
17 | const [isDeleting, setIsDeleting] = useState(false);
18 | const [didDelete, setDidDelete] = useState(false);
19 |
20 | useEffect(() => {
21 | if (open) {
22 | setIsDeleting(false);
23 | setDidDelete(false);
24 | }
25 | }, [open]);
26 |
27 | const resultName = `Block${selectedBlocks?.length > 1 ? "s" : ""}`;
28 |
29 | return (
30 | 1 ? "these blocks" : "this block"
35 | }? This action cannot be undone.`}
36 | color="red"
37 | Button={
38 | {
41 | console.log("DELETING", selectedBlock, selectedBlocks);
42 | setIsDeleting(true);
43 | let status;
44 | let error;
45 |
46 | if (selectedBlock) {
47 | const {
48 | data: deleteExercisesResult,
49 | error: deleteExercisesError,
50 | } = await supabase
51 | .from("exercise")
52 | .delete()
53 | .match({ block: selectedBlock.id, is_block_template: true });
54 | console.log("deleteExercisesResult", deleteExercisesResult);
55 | if (deleteExercisesError) {
56 | console.error(deleteExercisesError);
57 | error = deleteExercisesError;
58 | }
59 |
60 | const {
61 | data: updateExercisesResult,
62 | error: updateExercisesError,
63 | } = await supabase
64 | .from("exercise")
65 | .update({ block: null })
66 | .match({ block: selectedBlock.id, is_block_template: false });
67 | console.log("updateExercisesResult", updateExercisesResult);
68 | if (
69 | updateExercisesError &&
70 | !Array.isArray(updateExercisesError)
71 | ) {
72 | console.error(updateExercisesError);
73 | error = updateExercisesError;
74 | }
75 |
76 | const { data: deleteBlockResult, error: deleteBlockError } =
77 | await supabase
78 | .from("block")
79 | .delete()
80 | .eq("id", selectedBlock.id);
81 | console.log("deleteBlockResult", deleteBlockResult);
82 | if (deleteBlockError) {
83 | console.error(deleteBlockError);
84 | error = deleteBlockError;
85 | }
86 | } else if (selectedBlocks) {
87 | const {
88 | data: deleteExercisesResult,
89 | error: deleteExercisesError,
90 | } = await supabase
91 | .from("exercise")
92 | .delete()
93 | .match({ is_block_template: true })
94 | .in(
95 | "block",
96 | selectedBlocks.map((block) => block.id)
97 | );
98 | console.log("deleteExercisesResult", deleteExercisesResult);
99 | if (deleteExercisesError) {
100 | console.error(deleteExercisesError);
101 | error = deleteExercisesError;
102 | }
103 |
104 | const {
105 | data: updateExercisesResult,
106 | error: updateExercisesError,
107 | } = await supabase
108 | .from("exercise")
109 | .update({ block: null })
110 | .match({ is_block_template: false })
111 | .in(
112 | "block",
113 | selectedBlocks.map((block) => block.id)
114 | );
115 | console.log("updateExercisesResult", updateExercisesResult);
116 | if (
117 | updateExercisesError &&
118 | !Array.isArray(updateExercisesError)
119 | ) {
120 | console.error(updateExercisesError);
121 | error = updateExercisesError;
122 | }
123 |
124 | const { data: deleteBlocksResult, error: deleteBlocksError } =
125 | await supabase
126 | .from("block")
127 | .delete()
128 | .in(
129 | "id",
130 | selectedBlocks.map((block) => block.id)
131 | );
132 | console.log("deleteBlocksResult", deleteBlocksResult);
133 | if (deleteBlocksError) {
134 | console.error(deleteBlocksError);
135 | error = deleteBlocksError;
136 | }
137 | }
138 |
139 | setIsDeleting(false);
140 | setDidDelete(true);
141 | if (error) {
142 | status = {
143 | type: "failed",
144 | title: `Failed to delete ${resultName}`,
145 | message: error.message,
146 | };
147 | } else {
148 | status = {
149 | type: "succeeded",
150 | title: `Successfully deleted ${resultName}`,
151 | };
152 | }
153 | console.log("status", status);
154 | setDeleteBlockStatus(status);
155 | setShowDeleteBlockNotification(true);
156 | setOpen(false);
157 | setSelectedBlock?.();
158 | setSelectedBlocks?.();
159 | }}
160 | className="inline-flex w-full justify-center rounded-md border border-transparent bg-red-600 px-4 py-2 text-base font-medium text-white shadow-sm hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-red-500 focus:ring-offset-2 sm:ml-3 sm:w-auto sm:text-sm"
161 | >
162 | {/* eslint-disable-next-line no-nested-ternary */}
163 | {isDeleting
164 | ? `Deleting ${resultName}...`
165 | : didDelete
166 | ? `Deleted ${resultName}!`
167 | : `Delete ${resultName}`}
168 |
169 | }
170 | >
171 | );
172 | }
173 |
--------------------------------------------------------------------------------
/components/Footer.jsx:
--------------------------------------------------------------------------------
1 | import { useRouter } from "next/router";
2 | import { MailIcon } from "@heroicons/react/outline";
3 | import MyLink from "./MyLink";
4 |
5 | const navigation = {
6 | main: [
7 | { name: "Terms of Use", href: "/terms" },
8 | { name: "Privacy Policy", href: "/privacy" },
9 | ],
10 | social: [
11 | {
12 | name: "Email",
13 | href: "mailto:contact@repsetter.com?subject=Repsetter",
14 | icon: (props) => (
15 |
20 | ),
21 | },
22 | {
23 | name: "GitHub",
24 | href: "https://github.com/zakaton/repsetter",
25 | icon: (props) => (
26 |
27 |
32 |
33 | ),
34 | },
35 | {
36 | name: "LinkedIn",
37 | href: "https://www.linkedin.com/company/ukaton",
38 | icon: (props) => (
39 |
40 |
45 |
46 | ),
47 | },
48 | {
49 | name: "Twitter",
50 | href: "https://twitter.com/ConcreteSciFi",
51 | icon: (props) => (
52 |
53 |
54 |
55 | ),
56 | },
57 |
58 | {
59 | name: "Instagram",
60 | href: "https://www.instagram.com/concretescifi",
61 | icon: (props) => (
62 |
63 |
68 |
69 | ),
70 | },
71 | ].filter(Boolean),
72 | };
73 |
74 | function classNames(...classes) {
75 | return classes.filter(Boolean).join(" ");
76 | }
77 |
78 | export default function Footer() {
79 | const router = useRouter();
80 |
81 | return (
82 |
129 | );
130 | }
131 |
--------------------------------------------------------------------------------
/utils/withings.js:
--------------------------------------------------------------------------------
1 | import crypto from "crypto";
2 | import fetch from "node-fetch";
3 |
4 | function getUnixTimestamp() {
5 | return Math.floor(Date.now() / 1000);
6 | }
7 |
8 | export function getWithingsAuthURL(state = "state") {
9 | const params = new URLSearchParams();
10 | params.append("response_type", "code");
11 | params.append("client_id", process.env.NEXT_PUBLIC_WITHINGS_CLIENT_ID);
12 | params.append("state", state);
13 | params.append("scope", "user.metrics");
14 | params.append(
15 | "redirect_uri",
16 | process.env.NEXT_PUBLIC_URL + process.env.NEXT_PUBLIC_WITHINGS_REDIRECT_URI
17 | );
18 | return process.env.NEXT_PUBLIC_WITHINGS_AUTH_ENDPOINT + "?" + params;
19 | }
20 |
21 | export async function getWithingsAccessToken(code) {
22 | const params = new URLSearchParams();
23 | params.append("action", "requesttoken");
24 | params.append("grant_type", "authorization_code");
25 | params.append("client_id", process.env.NEXT_PUBLIC_WITHINGS_CLIENT_ID);
26 | params.append("client_secret", process.env.WITHINGS_SECRET);
27 | params.append("code", code);
28 | params.append(
29 | "redirect_uri",
30 | process.env.NEXT_PUBLIC_URL + process.env.NEXT_PUBLIC_WITHINGS_REDIRECT_URI
31 | );
32 |
33 | const response = await fetch(
34 | `${process.env.NEXT_PUBLIC_WITHINGS_TARGET_ENDPOINT}/v2/oauth2`,
35 | {
36 | method: "POST",
37 | body: params,
38 | }
39 | );
40 | const json = await response.json();
41 | console.log("withings access token json", json);
42 | return json;
43 | }
44 | export async function refreshWithingsAccessToken(refreshToken) {
45 | const params = new URLSearchParams();
46 | params.append("action", "requesttoken");
47 | params.append("grant_type", "refresh_token");
48 | params.append("client_id", process.env.NEXT_PUBLIC_WITHINGS_CLIENT_ID);
49 | params.append("client_secret", process.env.WITHINGS_SECRET);
50 | params.append("refresh_token", refreshToken);
51 | params.append(
52 | "redirect_uri",
53 | process.env.NEXT_PUBLIC_URL + process.env.NEXT_PUBLIC_WITHINGS_REDIRECT_URI
54 | );
55 | const response = await fetch(
56 | `${process.env.NEXT_PUBLIC_WITHINGS_TARGET_ENDPOINT}/v2/oauth2`,
57 | {
58 | method: "POST",
59 | body: params,
60 | }
61 | );
62 | const json = await response.json();
63 | console.log("withings refresh token json", json);
64 | return json;
65 | }
66 |
67 | const withingsAppliList = [1]; // user.metrics
68 | export async function subscribeToWithingsNotifications(accessToken, appli) {
69 | const params = new URLSearchParams();
70 | params.append("action", "subscribe");
71 | params.append(
72 | "callbackurl",
73 | process.env.NEXT_PUBLIC_URL +
74 | process.env.NEXT_PUBLIC_WITHINGS_NOTIFICATIONS_URI
75 | );
76 | params.append("appli", appli);
77 |
78 | const response = await fetch(
79 | `${process.env.NEXT_PUBLIC_WITHINGS_TARGET_ENDPOINT}/notify`,
80 | {
81 | method: "POST",
82 | body: params,
83 | headers: {
84 | Authorization: "Bearer " + accessToken,
85 | },
86 | }
87 | );
88 | const json = await response.json();
89 | console.log("withings subscribe json", json, appli);
90 | return json;
91 | }
92 | export async function subscribeToAllWithingsNotifications(accessToken) {
93 | const jsonResponses = await Promise.all(
94 | withingsAppliList.map((appli) =>
95 | subscribeToWithingsNotifications(accessToken, appli)
96 | )
97 | );
98 | return jsonResponses;
99 | }
100 | export async function revokeWithingsNotifications(accessToken, appli) {
101 | const params = new URLSearchParams();
102 | params.append("action", "revoke");
103 | params.append(
104 | "callbackurl",
105 | process.env.NEXT_PUBLIC_URL +
106 | process.env.NEXT_PUBLIC_WITHINGS_NOTIFICATIONS_URI
107 | );
108 | params.append("appli", appli);
109 |
110 | const response = await fetch(
111 | `${process.env.NEXT_PUBLIC_WITHINGS_TARGET_ENDPOINT}/notify`,
112 | {
113 | method: "POST",
114 | body: params,
115 | headers: {
116 | Authorization: "Bearer " + accessToken,
117 | },
118 | }
119 | );
120 | const json = await response.json();
121 | console.log("withings revoke json", json, appli);
122 | return json;
123 | }
124 | export async function revokeAllWithingsNotifications(accessToken) {
125 | const jsonResponses = await Promise.all(
126 | withingsAppliList.map((appli) =>
127 | revokeWithingsNotifications(accessToken, appli)
128 | )
129 | );
130 | return jsonResponses;
131 | }
132 |
133 | export async function getWithingsMeasure(accessToken, startdate, enddate) {
134 | console.log("getWithingsMeasure", accessToken, startdate, enddate);
135 | const params = new URLSearchParams();
136 | params.append("action", "getmeas");
137 | params.append("meastypes", "1,6");
138 | params.append("category", "1");
139 |
140 | if (startdate) {
141 | params.append("startdate", startdate);
142 | }
143 | if (enddate) {
144 | params.append("enddate", enddate);
145 | }
146 |
147 | const response = await fetch(
148 | `${process.env.NEXT_PUBLIC_WITHINGS_TARGET_ENDPOINT}/measure`,
149 | {
150 | method: "POST",
151 | body: params,
152 | headers: {
153 | Authorization: "Bearer " + accessToken,
154 | },
155 | }
156 | );
157 | const json = await response.json();
158 | console.log("withings getGetmeas json", json);
159 | return json;
160 | }
161 |
162 | export async function listWithingsNotifications(
163 | accessToken,
164 | appli = withingsAppliList[0]
165 | ) {
166 | const params = new URLSearchParams();
167 | params.append("action", "list");
168 | params.append("appli", appli);
169 |
170 | const response = await fetch(
171 | `${process.env.NEXT_PUBLIC_WITHINGS_TARGET_ENDPOINT}/notify`,
172 | {
173 | method: "POST",
174 | body: params,
175 | headers: {
176 | Authorization: "Bearer " + accessToken,
177 | },
178 | }
179 | );
180 | const json = await response.json();
181 | console.log("withings list notifications json", json, appli);
182 | return json;
183 | }
184 |
185 | export async function getNonce() {
186 | const params = new URLSearchParams();
187 |
188 | const action = "getnonce";
189 | const timestamp = getUnixTimestamp();
190 |
191 | params.append("action", action);
192 | params.append("client_id", process.env.NEXT_PUBLIC_WITHINGS_CLIENT_ID);
193 | params.append("timestamp", timestamp);
194 |
195 | const signatureString = [
196 | action,
197 | process.env.WITHINGS_CLIENT_ID,
198 | timestamp,
199 | ].join(",");
200 | const signature = crypto
201 | .createHmac("sha256", process.env.WITHINGS_SECRET)
202 | .update(signatureString)
203 | .digest("hex");
204 | console.log(signatureString, signature);
205 | params.append("signature", signature);
206 |
207 | const response = await fetch(
208 | `${process.env.NEXT_PUBLIC_WITHINGS_TARGET_ENDPOINT}/v2/signature`,
209 | { method: "POST", body: params }
210 | );
211 | const json = await response.json();
212 | console.log("nonce json", json);
213 | return json;
214 | }
215 |
--------------------------------------------------------------------------------