diff --git a/www/backend/helpar/brands/schema.py b/www/backend/helpar/brands/schema.py index e8a375a..d111b8b 100644 --- a/www/backend/helpar/brands/schema.py +++ b/www/backend/helpar/brands/schema.py @@ -33,3 +33,9 @@ class CreateMessageRequest(BaseModel): class CanonicalBrandInfoRequest(BaseModel): canonical_name: str + + +class BrandDashboardRequest(BaseModel): + start_date: str + end_date: str + brand_id: UUID4 diff --git a/www/backend/helpar/brands/urls.py b/www/backend/helpar/brands/urls.py index be622ec..8627ce3 100644 --- a/www/backend/helpar/brands/urls.py +++ b/www/backend/helpar/brands/urls.py @@ -33,4 +33,5 @@ path("brands//threads//messages/", brand_messages, name="brand-messages"), path(r"brands//products//faqs/", brand_product_faqs), path(r"brands/canonical-brand-info//", canonical_brand_info_view), + path(r"brands//dashboard/", brand_dashboard_view), ] diff --git a/www/backend/helpar/brands/uses.py b/www/backend/helpar/brands/uses.py index f1749eb..1eceb7a 100644 --- a/www/backend/helpar/brands/uses.py +++ b/www/backend/helpar/brands/uses.py @@ -44,3 +44,7 @@ def post_llm_prompt(message: Message) -> Optional[Message]: return Message.objects.create( **{"content": response, "thread": message.thread, "actor": LLM_ACTOR, "created_at": timezone.now()} ) + + +def brand_dashboard(params: BrandDashboardRequest) -> Tuple[status, Dict[str, Any]]: + return (status.HTTP_200_OK, {}) diff --git a/www/backend/helpar/brands/views.py b/www/backend/helpar/brands/views.py index d603e67..7e82328 100644 --- a/www/backend/helpar/brands/views.py +++ b/www/backend/helpar/brands/views.py @@ -1,3 +1,4 @@ +import logging from rest_framework.response import Response from rest_framework import status from rest_framework.decorators import api_view @@ -13,6 +14,9 @@ from helpar.lib.constants import * +logger = logging.getLogger("helpar") + + class BrandViewSet(BaseViewSet): queryset = Brand.objects.all() model = Brand @@ -335,3 +339,16 @@ def create(self, request: Request, brand_id: str, thread_id: str, *args, **kwarg messages = [message, llm_message] serializer = self.serializer_class(messages, many=True) return Response(status=status.HTTP_201_CREATED, data={"data": serializer.data}) + + +@api_view(["GET"]) +def brand_dashboard_view(request: Request, brand_id: uuid.UUID) -> Response: + try: + start_date = request.query_params["start_date"] + end_date = request.query_params["end_date"] + params = BrandDashboardRequest(start_date=start_date, end_date=end_date, brand_id=brand_id) + (resp_status, data) = brand_dashboard(params) + return Response(status=resp_status, data={"success": True, "data": data}) + except Exception as err: + logger.error("Error retrieving dashboard data: %s", str(err)) + return Response(status=status.HTTP_500_INTERNAL_SERVER_ERROR, data={"success": False, "details": str(err)}) diff --git a/www/backend/helpar/helpar/lib/middlewares.py b/www/backend/helpar/helpar/lib/middlewares.py index f52be2d..ab256a6 100644 --- a/www/backend/helpar/helpar/lib/middlewares.py +++ b/www/backend/helpar/helpar/lib/middlewares.py @@ -27,7 +27,7 @@ def __call__(self, request: Request) -> MiddlewareResponse: class CatchallMiddleware(BaseMiddleware): def __init__(self, get_response: Callable[[Request], MiddlewareResponse]): super().__init__(get_response) - self.logger = logging.getLogger(__name__) + self.logger = logging.getLogger("helpar") @staticmethod def _normalize_error_msg(msg: str) -> str: @@ -60,7 +60,7 @@ def __call__(self, request: Request): class LoggingMiddleware(BaseMiddleware): def __init__(self, get_response: Callable[[Request], MiddlewareResponse]): super().__init__(get_response) - self.logger = logging.getLogger(__name__) + self.logger = logging.getLogger("helpar") def __call__(self, request: Request) -> MiddlewareResponse: method = request.method or "" @@ -99,7 +99,7 @@ def is_unauthenticated_action(self, request: Request) -> bool: class JwtAuthMiddleware(BaseMiddleware): def __init__(self, get_response: Callable[[Request], MiddlewareResponse]): super().__init__(get_response) - self.logger = logging.getLogger(__name__) + self.logger = logging.getLogger("helpar") self.unauthenticated_routes = UnauthenticatedRoutes() def __call__(self, request: Request) -> MiddlewareResponse: diff --git a/www/backend/helpar/helpar/lib/services.py b/www/backend/helpar/helpar/lib/services.py index c4eafda..1cb1180 100644 --- a/www/backend/helpar/helpar/lib/services.py +++ b/www/backend/helpar/helpar/lib/services.py @@ -1,6 +1,7 @@ import logging import base64 import json +import uuid import datetime as dt import jwt import httpx @@ -8,7 +9,7 @@ from django.conf import settings from t import * -logger = logging.getLogger(__name__) +logger = logging.getLogger("helpar") client = OpenAI( api_key=settings.OPENAI_API_KEY, @@ -138,3 +139,42 @@ def _decode_jwt(token: str): decoded_bytes = base64.urlsafe_b64decode(payload) decoded_payload = decoded_bytes.decode("utf-8") return json.loads(decoded_payload) + + +# TODO: We can cache these somehow so that a given brand and set of dates +# only needs to be queried once + + +class DashboardStatistics: + def __init__(self, brand_id: uuid.UUID, start_date: str, end_date: str): + self.brand_id = brand_id + self.start_date = start_date + self.end_date = end_date + self._data = { + "num_customers": 0, + "preferences_recorded": 0, + "frustrations_resolved": 0, + "reorder_revenue": 0, + "customer_data": { + "nps": 0, + "customer_types": {}, + "registrations": [ + { + "user_name": "", + "user_identifier": "", + } + ], + }, + "setup_process": { + "num_happy_setups": 0, + "num_sad_setups": 0, + "num_neutral_setups": 0, + "setup_process_breakdown": {}, + }, + "reorder_revenue_breakdown": {}, + "support_topics": {}, + "social_clicks": {}, + } + + def data(self) -> Dict[str, Any]: + return self._data diff --git a/www/backend/helpar/helpar/settings/__init__.py b/www/backend/helpar/helpar/settings/__init__.py index de33cfc..41049a7 100644 --- a/www/backend/helpar/helpar/settings/__init__.py +++ b/www/backend/helpar/helpar/settings/__init__.py @@ -8,7 +8,7 @@ datefmt="%Y/%m/%d %H:%M:%S", ) -logger = logging.getLogger(__name__) +logger = logging.getLogger("helpar") env = os.environ["ENV"] proc = multiprocessing.current_process() diff --git a/www/backend/helpar/users/migrations/0018_oauthrequest_enterprise.py b/www/backend/helpar/users/migrations/0018_oauthrequest_enterprise.py new file mode 100644 index 0000000..af94b63 --- /dev/null +++ b/www/backend/helpar/users/migrations/0018_oauthrequest_enterprise.py @@ -0,0 +1,17 @@ +# Generated by Django 5.0 on 2023-12-30 16:18 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("users", "0017_alter_user_username"), + ] + + operations = [ + migrations.AddField( + model_name="oauthrequest", + name="enterprise", + field=models.BooleanField(default=False), + ), + ] diff --git a/www/backend/helpar/users/migrations/0019_pkcerequest_enterprise.py b/www/backend/helpar/users/migrations/0019_pkcerequest_enterprise.py new file mode 100644 index 0000000..cf18859 --- /dev/null +++ b/www/backend/helpar/users/migrations/0019_pkcerequest_enterprise.py @@ -0,0 +1,17 @@ +# Generated by Django 5.0 on 2023-12-30 16:24 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("users", "0018_oauthrequest_enterprise"), + ] + + operations = [ + migrations.AddField( + model_name="pkcerequest", + name="enterprise", + field=models.BooleanField(default=False), + ), + ] diff --git a/www/backend/helpar/users/migrations/0020_session.py b/www/backend/helpar/users/migrations/0020_session.py new file mode 100644 index 0000000..d26bdf9 --- /dev/null +++ b/www/backend/helpar/users/migrations/0020_session.py @@ -0,0 +1,37 @@ +# Generated by Django 5.0 on 2023-12-31 03:11 + +import django.db.models.deletion +import uuid +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("users", "0019_pkcerequest_enterprise"), + ] + + operations = [ + migrations.CreateModel( + name="Session", + fields=[ + ( + "id", + models.UUIDField( + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + ), + ), + ("enterprise", models.BooleanField(default=False)), + ("created_at", models.DateTimeField()), + ( + "user", + models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to="users.user"), + ), + ], + options={ + "db_table": "sessions", + }, + ), + ] diff --git a/www/backend/helpar/users/migrations/0021_delete_session.py b/www/backend/helpar/users/migrations/0021_delete_session.py new file mode 100644 index 0000000..5223a5f --- /dev/null +++ b/www/backend/helpar/users/migrations/0021_delete_session.py @@ -0,0 +1,15 @@ +# Generated by Django 5.0 on 2023-12-31 03:14 + +from django.db import migrations + + +class Migration(migrations.Migration): + dependencies = [ + ("users", "0020_session"), + ] + + operations = [ + migrations.DeleteModel( + name="Session", + ), + ] diff --git a/www/backend/helpar/users/models.py b/www/backend/helpar/users/models.py index 887178b..4b2becc 100644 --- a/www/backend/helpar/users/models.py +++ b/www/backend/helpar/users/models.py @@ -46,6 +46,7 @@ class Meta: code: models.CharField = models.CharField(max_length=255, unique=True, db_index=True) user: models.ForeignKey = models.ForeignKey(User, on_delete=models.CASCADE) + enterprise: models.BooleanField = models.BooleanField(default=False) created_at: models.DateTimeField = models.DateTimeField(null=True) expiry: models.DateTimeField = models.DateTimeField() deleted_at: models.DateTimeField = models.DateTimeField(null=True) @@ -58,6 +59,7 @@ class Meta: id: models.UUIDField = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) provider: models.CharField = models.CharField(max_length=255) origin: models.CharField = models.CharField(max_length=255) + enterprise: models.BooleanField = models.BooleanField(default=False) state: models.CharField = models.CharField(max_length=255, db_index=True) created_at: models.DateTimeField = models.DateTimeField() expiry: models.DateTimeField = models.DateTimeField() diff --git a/www/backend/helpar/users/schema.py b/www/backend/helpar/users/schema.py index 0748735..ecbbfee 100644 --- a/www/backend/helpar/users/schema.py +++ b/www/backend/helpar/users/schema.py @@ -25,6 +25,7 @@ def canoncial_phone(self) -> str: class PKCEAuthRequest(BaseModel): code: Annotated[str, StringConstraints(min_length=6, max_length=6)] + enterprise: bool = False class UpdateUserRequest(BaseModel): diff --git a/www/backend/helpar/users/uses.py b/www/backend/helpar/users/uses.py index 8cc264e..69aea6a 100644 --- a/www/backend/helpar/users/uses.py +++ b/www/backend/helpar/users/uses.py @@ -17,7 +17,7 @@ from helpar.lib.utils import * from helpar.lib.services import AppleAuthentication -logger = logging.getLogger(__name__) +logger = logging.getLogger("helpar") scopes = [ "https://www.googleapis.com/auth/userinfo.profile", @@ -34,7 +34,7 @@ def _fetch_google_user_info(token: str) -> Optional[Dict[str, Any]]: return response.json() -def google_auth(origin: str) -> str: +def google_auth(origin: str, enterprise: bool) -> str: mins_expiry = 5 flow = Flow.from_client_config( client_config={ @@ -55,6 +55,7 @@ def google_auth(origin: str) -> str: _ = OauthRequest.objects.create( **{ "provider": "google", + "enterprise": enterprise, "state": params["state"][0], "created_at": timezone.now(), "origin": origin, @@ -140,6 +141,7 @@ def twilio_phone_auth_verification( if twilio_auth.expiry <= timezone.now(): twilio_auth.deleted_at = timezone.now() twilio_auth.save() + logger.error("Twilio verification code expired.") return (status.HTTP_400_BAD_REQUEST, {"success": False, "message": "Verification code expired."}) response = httpx.post( @@ -176,19 +178,33 @@ def twilio_phone_auth_verification( def pkce_auth(params: PKCEAuthRequest) -> Tuple[status, Dict[str, Any]]: - pkce_request = PKCERequest.objects.filter(code=params.code, deleted_at__isnull=True).order_by("-created_at").first() + from brands.models import Brand + + pkce_request = ( + PKCERequest.objects.filter(code=params.code, enterprise=params.enterprise, deleted_at__isnull=True) + .order_by("-created_at") + .first() + ) if not pkce_request: + logger.error("No PKCE request found for code: %s", params.code) return (status.HTTP_400_BAD_REQUEST, {"success": False, "message": "Invalid code."}) if pkce_request.expiry <= timezone.now(): + logger.error("PKCE code %s expired.", params.code) pkce_request.deleted_at = timezone.now() pkce_request.save() return (status.HTTP_400_BAD_REQUEST, {"success": False, "message": "Code expired."}) - data = UserSerializer(pkce_request.user).data pkce_request.deleted_at = timezone.now() pkce_request.save() + if params.enterprise: + brand = Brand.objects.filter(users__id=pkce_request.user.id).first() + if not brand: + logger.error("User %s not associated with a brand.", pkce_request.user.id) + return (status.HTTP_400_BAD_REQUEST, {"success": False, "message": "User not associated with a brand."}) + + data = UserSerializer(pkce_request.user).data return (status.HTTP_200_OK, {"success": True, "user": data}) @@ -216,11 +232,13 @@ def apple_auth_callback(form: AppleAuthForm) -> HttpResponse: .first() ) if not oauth_req: + logger.error("No oauth request found for state: %s", form.cleaned_data["state"]) return HttpResponseServerError( content_type="application/json", content=json.dumps({"success": False, "details": "No oauth request found for state."}), ) if oauth_req.expiry < timezone.now(): + logger.error("Apple auth request expired.") return HttpResponseBadRequest( content_type="application/json", content=json.dumps({"success": False, "details": "Apple auth request expired."}), @@ -233,6 +251,9 @@ def apple_auth_callback(form: AppleAuthForm) -> HttpResponse: code = random_digits() expiry = timezone.now() + dt.timedelta(minutes=mins_expiry) + path = "d/hq?code=" if oauth_req.enterprise else "m/hq?code=" + url = oauth_req.origin + path + code + if not email or not first_name or not last_name: derived_email = AppleAuthentication.derive_user_email_with_idcode(form.cleaned_data["id_token"]) if not derived_email: @@ -244,13 +265,13 @@ def apple_auth_callback(form: AppleAuthForm) -> HttpResponse: if User.objects.filter(email=derived_email).exists(): user = User.objects.get(email=derived_email) _ = PKCERequest.objects.create(code=code, expiry=expiry, user_id=user.id, created_at=timezone.now()) - return HttpResponseRedirect(oauth_req.origin + "hq?code=" + code) + return HttpResponseRedirect(url) username = first_name + " " + last_name if User.objects.filter(email=email, username=username).exists(): user = User.objects.get(email=email, username=username) _ = PKCERequest.objects.create(code=code, expiry=expiry, user_id=user.id, created_at=timezone.now()) - return HttpResponseRedirect(oauth_req.origin + "hq?code=" + code) + return HttpResponseRedirect(url) user_data = { "email": email, @@ -270,7 +291,7 @@ def apple_auth_callback(form: AppleAuthForm) -> HttpResponse: _ = PKCERequest.objects.create(code=code, expiry=expiry, user_id=user.id, created_at=timezone.now()) - return HttpResponseRedirect(oauth_req.origin + "hq?code=" + code) + return HttpResponseRedirect(oauth_req.origin + "m/hq?code=" + code) def update_user(params: UpdateUserRequest, user_id: str) -> Tuple[status, Dict[str, Any]]: diff --git a/www/backend/helpar/users/views.py b/www/backend/helpar/users/views.py index 573bc7d..edd0437 100644 --- a/www/backend/helpar/users/views.py +++ b/www/backend/helpar/users/views.py @@ -13,7 +13,7 @@ from t import * from helpar.lib.utils import * -logger = logging.getLogger(__name__) +logger = logging.getLogger("helpar") @api_view(["PATCH"]) @@ -31,7 +31,8 @@ def update_user_view(request: Request) -> Response: def google_auth_view(request: Request) -> Response: try: origin = request.META.get("HTTP_REFERER", request.META.get("HTTP_ORIGIN")) - authorization_url = google_auth(origin) + enterprise = "enterprise" in request.query_params + authorization_url = google_auth(origin, enterprise) return Response(data={"success": True, "redirect": authorization_url}) except Exception as err: logger.exception("Google auth error: %s", str(err)) @@ -57,11 +58,11 @@ def google_auth_callback_view(request: Request) -> Response: ) if rstatus != status.HTTP_200_OK: - url = oauth_req.origin + "?alert=auth" + url = oauth_req.origin + "m?alert=auth" return HttpResponseRedirect(url) if oauth_req.expiry < timezone.now(): - url = oauth_req.origin + "?alert=auth" + url = oauth_req.origin + "m?alert=auth" return HttpResponseRedirect(url) oauth_req.deleted_at = timezone.now() @@ -71,8 +72,12 @@ def google_auth_callback_view(request: Request) -> Response: code = random_digits() expiry = timezone.now() + dt.timedelta(minutes=mins_expiry) - _ = PKCERequest.objects.create(code=code, expiry=expiry, user_id=user["id"], created_at=timezone.now()) - url = oauth_req.origin + "hq?code=" + code + _ = PKCERequest.objects.create( + code=code, enterprise=oauth_req.enterprise, expiry=expiry, user_id=user["id"], created_at=timezone.now() + ) + + path = "m/hq?code=" if not oauth_req.enterprise else "d/dashboard?code=" + url = oauth_req.origin + path + code return HttpResponseRedirect(url) except Exception as err: logger.exception("Google auth callback error: %s", str(err)) diff --git a/www/frontend/helpar/package.json b/www/frontend/helpar/package.json index 5566280..2cdbe1c 100644 --- a/www/frontend/helpar/package.json +++ b/www/frontend/helpar/package.json @@ -9,40 +9,51 @@ "@radix-ui/react-alert-dialog": "1.0.5", "@radix-ui/react-avatar": "1.0.4", "@radix-ui/react-checkbox": "1.0.4", + "@radix-ui/react-dropdown-menu": "2.0.6", "@radix-ui/react-hover-card": "1.0.7", "@radix-ui/react-icons": "1.3.0", "@radix-ui/react-label": "2.0.2", "@radix-ui/react-menubar": "1.0.4", "@radix-ui/react-navigation-menu": "1.1.4", + "@radix-ui/react-popover": "1.0.7", "@radix-ui/react-progress": "1.0.3", "@radix-ui/react-radio-group": "1.1.3", "@radix-ui/react-scroll-area": "1.0.5", "@radix-ui/react-select": "2.0.0", + "@radix-ui/react-separator": "1.0.3", "@radix-ui/react-slot": "1.0.2", "@radix-ui/react-switch": "1.0.3", "@radix-ui/react-tabs": "1.0.4", + "@radix-ui/react-toast": "1.1.5", "@radix-ui/react-toggle": "1.0.3", "@radix-ui/react-toggle-group": "1.0.4", "@reduxjs/toolkit": "2.0.1", + "@types/leaflet": "1.9.8", + "add": "2.0.6", "autoprefixer": "10.4.16", "axios": "1.6.2", "class-variance-authority": "0.7.0", "clsx": "2.0.0", "cmdk": "0.2.0", + "date-fns": "3.0.6", "eslint": "8.56.0", + "leaflet": "1.9.4", + "leaflet.heat": "0.2.0", "lucide-react": "0.294.0", "postcss": "8.4.32", "prettier": "3.0.3", "react": "18.2.0", - "react-day-picker": "8.9.1", + "react-day-picker": "8.10.0", "react-dom": "18.2.0", "react-hook-form": "7.49.0", "react-ionicons": "4.2.1", + "react-leaflet": "4.2.1", "react-redux": "9.0.2", "react-router-dom": "6.14.2", "react-scripts": "5.0.1", "react-slick": "0.29.0", "react-uuid": "2.0.0", + "recharts": "2.10.3", "slick-carousel": "1.8.1", "styled-components": "6.1.1", "swiper": "11.0.5", @@ -51,6 +62,7 @@ "tailwindcss-animate": "1.0.7", "validator": "13.11.0", "web-vitals": "2.1.4", + "yarn": "1.22.21", "zod": "3.22.4" }, "scripts": { diff --git a/www/frontend/helpar/src/components/AuthenticationForm.tsx b/www/frontend/helpar/src/components/AuthenticationForm.tsx new file mode 100644 index 0000000..cf0e5de --- /dev/null +++ b/www/frontend/helpar/src/components/AuthenticationForm.tsx @@ -0,0 +1,71 @@ +import React, { useState } from "react"; +import { useSelector } from "react-redux"; +import { LogoGoogle } from "react-ionicons"; +import { cn } from "../lib/utils"; +import { Button } from "./ui/button"; +import axios from "../services/Axios"; +import Events from "../services/Events"; +import config, { Environment, Module } from "../config"; +import { sanitizeError } from "../utils"; + +interface UserAuthFormProps extends React.HTMLAttributes {} + +const UserAuthForm = ({ className, ...props }: UserAuthFormProps) => { + const [loading, setLoading] = useState(false); + const [oauthError, setOauthError] = useState(null); + const session = useSelector((state: any) => state.user.session); + const events = React.useRef(new Events(session)); + + const oauthGoogleRequest = async () => { + try { + setLoading(true); + const response = await axios.post(`/users/auth/google/?enterprise=true`); + + if (response.status === 200 && response.data && response.data.redirect) { + window.location.href = response.data.redirect; + } else { + console.error("Failed to authenticate phone number:", response); + setOauthError("Failed to authenticate phone number."); + setLoading(false); + } + } catch (error: any) { + console.error("Validation failed:", error.toString()); + setOauthError(sanitizeError(error.toString())); + return; + } + }; + + return ( +
+
+ +

+ {oauthError} +

+
+ ); +}; + +export default UserAuthForm; diff --git a/www/frontend/helpar/src/components/dashboard/CustomerType.tsx b/www/frontend/helpar/src/components/dashboard/CustomerType.tsx new file mode 100644 index 0000000..0bd293e --- /dev/null +++ b/www/frontend/helpar/src/components/dashboard/CustomerType.tsx @@ -0,0 +1,68 @@ +import { PieChart, Pie, Legend, Cell, ResponsiveContainer } from "recharts"; + +const data = [ + { name: "Group A", value: 400 }, + { name: "Group B", value: 300 }, + { name: "Group C", value: 300 }, + { name: "Group D", value: 200 }, +]; +const COLORS = ["#0088FE", "#00C49F", "#FFBB28", "#FF8042"]; + +const renderColorfulLegendText = (value: number, entry: any) => { + const { color } = entry; + return ( + {`${value} - ${entry.payload.value}`} + ); +}; + +export function CustomerType() { + return ( + + {}}> + { + const RADIAN = Math.PI / 180; + const radius = innerRadius + (outerRadius - innerRadius) * 2.1; + const x = cx + radius * Math.cos(-midAngle * RADIAN); + const y = cy + radius * Math.sin(-midAngle * RADIAN); + + return ( + cx ? "start" : "end"} + dominantBaseline="central" + style={{ fontSize: 12, fontFamily: "Inter" }} + > + {`${name}: ${(percent * 100).toFixed(0)}%`} + + ); + }} + > + {data.map((entry, index) => ( + + ))} + + + + + ); +} diff --git a/www/frontend/helpar/src/components/dashboard/DateRangePicker.tsx b/www/frontend/helpar/src/components/dashboard/DateRangePicker.tsx new file mode 100644 index 0000000..2a58d3c --- /dev/null +++ b/www/frontend/helpar/src/components/dashboard/DateRangePicker.tsx @@ -0,0 +1,68 @@ +import * as React from "react"; +import { CalendarIcon } from "@radix-ui/react-icons"; +import { format } from "date-fns"; +import { DateRange } from "react-day-picker"; +import { cn } from "../../lib/utils"; +import { Button } from "./../ui/button"; +import { Calendar } from "./../ui/calendar"; +import { Popover, PopoverContent, PopoverTrigger } from "./../ui/popover"; + +interface CalendarDateRangePickerProps { + className?: string; + onChange: (from: Date, to: Date) => void; + startDate: Date; + endDate: Date; +} + +export function CalendarDateRangePicker(props: CalendarDateRangePickerProps) { + const [date, setDate] = React.useState({ + from: props.startDate, + to: props.endDate, + }); + + return ( +
+ + + + + + { + setDate(date); + if (date && date.from && date.to) { + props.onChange(date.from, date.to); + } + }} + numberOfMonths={2} + /> + + +
+ ); +} diff --git a/www/frontend/helpar/src/components/dashboard/MainNav.tsx b/www/frontend/helpar/src/components/dashboard/MainNav.tsx new file mode 100644 index 0000000..9fa8d01 --- /dev/null +++ b/www/frontend/helpar/src/components/dashboard/MainNav.tsx @@ -0,0 +1,40 @@ +import { Link } from "react-router-dom"; + +import { cn } from "../../lib/utils"; + +export function MainNav({ + className, + ...props +}: React.HTMLAttributes) { + return ( + + ); +} diff --git a/www/frontend/helpar/src/components/dashboard/Overview.tsx b/www/frontend/helpar/src/components/dashboard/Overview.tsx new file mode 100644 index 0000000..8cd953e --- /dev/null +++ b/www/frontend/helpar/src/components/dashboard/Overview.tsx @@ -0,0 +1,87 @@ +"use client"; + +import { Bar, BarChart, ResponsiveContainer, XAxis, YAxis } from "recharts"; + +const data = [ + { + name: "Jan", + total: Math.floor(Math.random() * 5000) + 1000, + }, + { + name: "Feb", + total: Math.floor(Math.random() * 5000) + 1000, + }, + { + name: "Mar", + total: Math.floor(Math.random() * 5000) + 1000, + }, + { + name: "Apr", + total: Math.floor(Math.random() * 5000) + 1000, + }, + { + name: "May", + total: Math.floor(Math.random() * 5000) + 1000, + }, + { + name: "Jun", + total: Math.floor(Math.random() * 5000) + 1000, + }, + { + name: "Jul", + total: Math.floor(Math.random() * 5000) + 1000, + }, + { + name: "Aug", + total: Math.floor(Math.random() * 5000) + 1000, + }, + { + name: "Sep", + total: Math.floor(Math.random() * 5000) + 1000, + }, + { + name: "Oct", + total: Math.floor(Math.random() * 5000) + 1000, + }, + { + name: "Nov", + total: Math.floor(Math.random() * 5000) + 1000, + }, + { + name: "Dec", + total: Math.floor(Math.random() * 5000) + 1000, + }, +]; + +export function Overview() { + return ( + + + + `$${value}`} + /> + + + + ); +} diff --git a/www/frontend/helpar/src/components/dashboard/RecentCustomers.tsx b/www/frontend/helpar/src/components/dashboard/RecentCustomers.tsx new file mode 100644 index 0000000..7719356 --- /dev/null +++ b/www/frontend/helpar/src/components/dashboard/RecentCustomers.tsx @@ -0,0 +1,63 @@ +import { Avatar, AvatarFallback, AvatarImage } from "../ui/avatar"; + +const data = [ + { + image: "/avatars/01.png", + name: "Olivia Martin", + fallback: "OM", + email: "olivia.martin@email.com", + session_duration: "5m", + }, + { + image: "/avatars/02.png", + name: "Jackson Lee", + fallback: "JL", + email: "jackson.lee@gmail.com", + session_duration: "2m", + }, + { + image: "/avatars/03.png", + name: "Isabella Nguyen", + fallback: "IN", + email: "isabella.nguyen@email.com", + session_duration: "3m", + }, + { + image: "/avatars/04.png", + name: "William Kim", + fallback: "WK", + email: "will@email.com", + session_duration: "1m", + }, + { + image: "/avatars/05.png", + name: "Sofia Davis", + fallback: "SD", + email: "sofiad123@email.com", + session_duration: "2m", + }, +]; + +export function RecentCustomers() { + return ( +
+
+

Name

+

Session Duration

+
+ {data.map((customer, index) => ( +
+ + + {customer.fallback} + +
+

{customer.name}

+

{customer.email}

+
+
{customer.session_duration}
+
+ ))} +
+ ); +} diff --git a/www/frontend/helpar/src/components/dashboard/Search.tsx b/www/frontend/helpar/src/components/dashboard/Search.tsx new file mode 100644 index 0000000..84be2ac --- /dev/null +++ b/www/frontend/helpar/src/components/dashboard/Search.tsx @@ -0,0 +1,13 @@ +import { Input } from "./../ui/input"; + +export function Search() { + return ( +
+ +
+ ); +} diff --git a/www/frontend/helpar/src/components/dashboard/SessionLocation.tsx b/www/frontend/helpar/src/components/dashboard/SessionLocation.tsx new file mode 100644 index 0000000..4c54f7e --- /dev/null +++ b/www/frontend/helpar/src/components/dashboard/SessionLocation.tsx @@ -0,0 +1,78 @@ +import { useEffect } from "react"; +import { MapContainer, TileLayer, useMap } from "react-leaflet"; +import L from "leaflet"; +import "leaflet/dist/leaflet.css"; +import "leaflet.heat"; + +type Location = { + lat: number; + lng: number; + value: number; +}; + +const data: Location[] = [ + { lat: 40.7128, lng: -74.006, value: 491 }, + { lat: 34.0522, lng: -118.2437, value: 153 }, + { lat: 41.8781, lng: -87.6298, value: 112 }, + { lat: 29.7604, lng: -95.3698, value: 148 }, + { lat: 33.4484, lng: -112.074, value: 265 }, + { lat: 39.9526, lng: -75.1652, value: 388 }, + { lat: 29.4241, lng: -98.4936, value: 146 }, + { lat: 32.7157, lng: -117.1611, value: 424 }, + { lat: 32.7767, lng: -96.797, value: 398 }, + { lat: 37.3382, lng: -121.8863, value: 290 }, + { lat: 30.2672, lng: -97.7431, value: 491 }, + { lat: 30.3322, lng: -81.6557, value: 341 }, + { lat: 32.7555, lng: -97.3308, value: 349 }, + { lat: 39.9612, lng: -82.9988, value: 209 }, + { lat: 35.2271, lng: -80.8431, value: 279 }, + { lat: 37.7749, lng: -122.4194, value: 419 }, + { lat: 39.7684, lng: -86.1581, value: 419 }, + { lat: 47.6062, lng: -122.3321, value: 301 }, + { lat: 39.7392, lng: -104.9903, value: 126 }, + { lat: 38.9072, lng: -77.0369, value: 387 }, +]; +const HeatmapLayer = () => { + const map = useMap(); + + useEffect(() => { + const heatPoints = data.map((location) => [ + location.lat, + location.lng, + location.value, + ]); + const heatLayer = (L as any) + .heatLayer(heatPoints, { + radius: 20, + minOpacity: 0.4, + blur: 0, + max: 500, + gradient: { + 0.2: "yellow", + 0.5: "orange", + 0.8: "red", + 1.0: "darkred", + }, + }) + .addTo(map); + return () => { + map.removeLayer(heatLayer); + }; + }, [map]); + + return null; +}; + +export function SessionLocation() { + return ( + // @ts-ignore + + + + + ); +} diff --git a/www/frontend/helpar/src/components/dashboard/SidebarNav.tsx b/www/frontend/helpar/src/components/dashboard/SidebarNav.tsx new file mode 100644 index 0000000..b31b404 --- /dev/null +++ b/www/frontend/helpar/src/components/dashboard/SidebarNav.tsx @@ -0,0 +1,42 @@ +"use client"; + +import { cn } from "../../lib/utils"; +import { Button } from "../ui/button"; + +export type NavbarItem = "Profile" | "Account" | "Notifications" | "Billing"; + +interface SidebarNavProps extends React.HTMLAttributes { + items: { + title: string; + }[]; + currentNavbarItem: NavbarItem; + onItemSelect: (item: NavbarItem) => void; +} + +const SidebarNav = (props: SidebarNavProps): React.ReactElement => { + return ( + + ); +}; + +export default SidebarNav; diff --git a/www/frontend/helpar/src/components/dashboard/SocialMedia.tsx b/www/frontend/helpar/src/components/dashboard/SocialMedia.tsx new file mode 100644 index 0000000..f63d378 --- /dev/null +++ b/www/frontend/helpar/src/components/dashboard/SocialMedia.tsx @@ -0,0 +1,70 @@ +import { PieChart, Pie, Legend, Cell, ResponsiveContainer } from "recharts"; + +const data = [ + { name: "Facebook", value: 400 }, + { name: "Twitter", value: 300 }, + { name: "Instagram", value: 300 }, + { name: "TikTok", value: 200 }, +]; +const COLORS = ["#0088FE", "#00C49F", "#FFBB28", "#FF8042"]; + +const renderColorfulLegendText = (value: number, entry: any) => { + const { color } = entry; + return ( + {`${value} - ${entry.payload.value}`} + ); +}; + +const renderCustomizedLabel = ({ + cx, + cy, + midAngle, + innerRadius, + outerRadius, + percent, + index, + name, +}: any) => { + const radius = innerRadius + (outerRadius - innerRadius) * 0.4; + const x = cx + radius * Math.cos((-midAngle * Math.PI) / 180); + const y = cy + radius * Math.sin((-midAngle * Math.PI) / 180); + + return ( + cx ? "start" : "end"} + dominantBaseline="central" + style={{ fontSize: 12, fontFamily: "Inter" }} + > + {`${name}: ${(percent * 100).toFixed(0)}%`} + + ); +}; + +export function SocialMedia() { + return ( + + + + {data.map((entry, index) => ( + + ))} + + + + + ); +} diff --git a/www/frontend/helpar/src/components/dashboard/Summary.tsx b/www/frontend/helpar/src/components/dashboard/Summary.tsx new file mode 100644 index 0000000..caf4afc --- /dev/null +++ b/www/frontend/helpar/src/components/dashboard/Summary.tsx @@ -0,0 +1,152 @@ +"use client"; + +import { + Line, + LineChart, + CartesianGrid, + Legend, + ResponsiveContainer, + XAxis, + YAxis, +} from "recharts"; +import { shortFormat } from "../../utils"; + +const data = [ + { + name: "Jan", + sessions: Math.floor(Math.random() * 5000) + 1000, + engagement: Math.floor(Math.random() * 5000) + 1000, + }, + { + name: "Feb", + sessions: Math.floor(Math.random() * 5000) + 1000, + engagement: Math.floor(Math.random() * 5000) + 1000, + }, + { + name: "Mar", + sessions: Math.floor(Math.random() * 5000) + 1000, + engagement: Math.floor(Math.random() * 5000) + 1000, + }, + { + name: "Apr", + sessions: Math.floor(Math.random() * 5000) + 1000, + engagement: Math.floor(Math.random() * 5000) + 1000, + }, + { + name: "May", + sessions: Math.floor(Math.random() * 5000) + 1000, + engagement: Math.floor(Math.random() * 5000) + 1000, + }, + { + name: "Jun", + sessions: Math.floor(Math.random() * 5000) + 1000, + engagement: Math.floor(Math.random() * 5000) + 1000, + }, + { + name: "Jul", + sessions: Math.floor(Math.random() * 5000) + 1000, + engagement: Math.floor(Math.random() * 5000) + 1000, + }, + { + name: "Aug", + sessions: Math.floor(Math.random() * 5000) + 1000, + engagement: Math.floor(Math.random() * 5000) + 1000, + }, + { + name: "Sep", + sessions: Math.floor(Math.random() * 5000) + 1000, + engagement: Math.floor(Math.random() * 5000) + 1000, + }, + { + name: "Oct", + sessions: Math.floor(Math.random() * 5000) + 1000, + engagement: Math.floor(Math.random() * 5000) + 1000, + }, + { + name: "Nov", + sessions: Math.floor(Math.random() * 5000) + 1000, + engagement: Math.floor(Math.random() * 5000) + 1000, + }, + { + name: "Dec", + sessions: Math.floor(Math.random() * 5000) + 1000, + engagement: Math.floor(Math.random() * 5000) + 1000, + }, +]; + +const renderColorfulLegendText = (value: string) => { + value = value[0].toUpperCase() + value.slice(1); + return {value}; +}; + +export function Summary() { + return ( + + + + shortFormat(value)} + /> + shortFormat(value)} + /> + + + + + + + ); +} diff --git a/www/frontend/helpar/src/components/dashboard/SupportTopics.tsx b/www/frontend/helpar/src/components/dashboard/SupportTopics.tsx new file mode 100644 index 0000000..49c8794 --- /dev/null +++ b/www/frontend/helpar/src/components/dashboard/SupportTopics.tsx @@ -0,0 +1,44 @@ +import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip } from "recharts"; + +const data = [ + { topic: "returns", value: 10 }, + { topic: "reorders", value: 37 }, + { topic: "getting started", value: 110 }, + { topic: "customer support", value: 20 }, + { topic: "refund", value: 55 }, + { topic: "shower head", value: 100 }, + { topic: "support", value: 55 }, + { topic: "tiny aligator", value: 3 }, +]; + +export function SupportTopics() { + return ( + + + + + + + + ); +} diff --git a/www/frontend/helpar/src/components/dashboard/TeamSwitcher.tsx b/www/frontend/helpar/src/components/dashboard/TeamSwitcher.tsx new file mode 100644 index 0000000..42afb89 --- /dev/null +++ b/www/frontend/helpar/src/components/dashboard/TeamSwitcher.tsx @@ -0,0 +1,208 @@ +"use client"; + +import * as React from "react"; +import { + CaretSortIcon, + CheckIcon, + PlusCircledIcon, +} from "@radix-ui/react-icons"; + +import { cn } from "./../../lib/utils"; +import { Avatar, AvatarFallback, AvatarImage } from "./../ui/avatar"; +import { Button } from "./../ui/button"; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, + CommandSeparator, +} from "./../ui/command"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "./../ui/dialog"; +import { Input } from "./../ui/input"; +import { Label } from "./../ui/label"; +import { Popover, PopoverContent, PopoverTrigger } from "./../ui/popover"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "./../ui/select"; + +const groups = [ + { + label: "Personal Account", + teams: [ + { + label: "Alicia Koch", + value: "personal", + }, + ], + }, + { + label: "Teams", + teams: [ + { + label: "Acme Inc.", + value: "acme-inc", + }, + { + label: "Monsters Inc.", + value: "monsters", + }, + ], + }, +]; + +type Team = (typeof groups)[number]["teams"][number]; + +type PopoverTriggerProps = React.ComponentPropsWithoutRef< + typeof PopoverTrigger +>; + +interface TeamSwitcherProps extends PopoverTriggerProps {} + +export default function TeamSwitcher({ className }: TeamSwitcherProps) { + const [open, setOpen] = React.useState(false); + const [showNewTeamDialog, setShowNewTeamDialog] = React.useState(false); + const [selectedTeam, setSelectedTeam] = React.useState( + groups[0].teams[0], + ); + + return ( + + + + + + + + + + No team found. + {groups.map((group) => ( + + {group.teams.map((team) => ( + { + setSelectedTeam(team); + setOpen(false); + }} + className="text-sm" + > + + + SC + + {team.label} + + + ))} + + ))} + + + + + + { + setOpen(false); + setShowNewTeamDialog(true); + }} + > + + Create Team + + + + + + + + + + Create team + + Add a new team to manage products and customers. + + +
+
+
+ + +
+
+ + +
+
+
+ + + + +
+
+ ); +} diff --git a/www/frontend/helpar/src/components/dashboard/TopNavigation.tsx b/www/frontend/helpar/src/components/dashboard/TopNavigation.tsx new file mode 100644 index 0000000..0634637 --- /dev/null +++ b/www/frontend/helpar/src/components/dashboard/TopNavigation.tsx @@ -0,0 +1,22 @@ +import React from "react"; +import TeamSwitcher from "./TeamSwitcher"; +import { MainNav } from "./MainNav"; +import { Search } from "./Search"; +import { UserNav } from "./UserNav"; + +const TopNavigation = (): React.JSX.Element => { + return ( +
+
+ + +
+ + +
+
+
+ ); +}; + +export default TopNavigation; diff --git a/www/frontend/helpar/src/components/dashboard/UserNav.tsx b/www/frontend/helpar/src/components/dashboard/UserNav.tsx new file mode 100644 index 0000000..58f2e31 --- /dev/null +++ b/www/frontend/helpar/src/components/dashboard/UserNav.tsx @@ -0,0 +1,60 @@ +import { Avatar, AvatarFallback, AvatarImage } from "./../ui/avatar"; +import { useSelector } from "react-redux"; +import { useNavigate } from "react-router-dom"; +import { Button } from "./../ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuGroup, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "./../ui/dropdown-menu"; +import { Session } from "../../services/Session"; + +export function UserNav() { + const navigate = useNavigate(); + const user = useSelector((state: any) => state.user.session); + return ( + + + + + + +
+

shadcn

+

+ {user.user?.email} +

+
+
+ + + navigate("/d/settings")} + > + Settings + + + Billing + + + + Session.logout()} + > + Log out + +
+
+ ); +} diff --git a/www/frontend/helpar/src/components/forms/AccountForm.tsx b/www/frontend/helpar/src/components/forms/AccountForm.tsx new file mode 100644 index 0000000..f9a3920 --- /dev/null +++ b/www/frontend/helpar/src/components/forms/AccountForm.tsx @@ -0,0 +1,208 @@ +import { zodResolver } from "@hookform/resolvers/zod"; +import { CalendarIcon, CaretSortIcon, CheckIcon } from "@radix-ui/react-icons"; +import { format } from "date-fns"; +import { useForm } from "react-hook-form"; +import * as z from "zod"; + +import { cn } from "../../lib/utils"; +import { Button } from "../ui/button"; +import { Calendar } from "../ui/calendar"; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, +} from "../ui/command"; +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "../ui/form"; +import { Input } from "../ui/input"; +import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover"; +import { Toast } from "../ui/toast"; + +const languages = [ + { label: "English", value: "en" }, + { label: "French", value: "fr" }, + { label: "German", value: "de" }, + { label: "Spanish", value: "es" }, + { label: "Portuguese", value: "pt" }, + { label: "Russian", value: "ru" }, + { label: "Japanese", value: "ja" }, + { label: "Korean", value: "ko" }, + { label: "Chinese", value: "zh" }, +] as const; + +const accountFormSchema = z.object({ + name: z + .string() + .min(2, { + message: "Name must be at least 2 characters.", + }) + .max(30, { + message: "Name must not be longer than 30 characters.", + }), + dob: z.date({ + required_error: "A date of birth is required.", + }), + language: z.string({ + required_error: "Please select a language.", + }), +}); + +type AccountFormValues = z.infer; + +// This can come from your database or API. +const defaultValues: Partial = { + // name: "Your name", + // dob: new Date("2023-01-23"), +}; + +export function AccountForm() { + const form = useForm({ + resolver: zodResolver(accountFormSchema), + defaultValues, + }); + + function onSubmit(data: AccountFormValues) { + Toast({ + title: "You submitted the form", + }); + } + + return ( +
+ + ( + + Name + + + + + This is the name that will be displayed on your profile and in + emails. + + + + )} + /> + ( + + Date of birth + + + + + + + + + date > new Date() || date < new Date("1900-01-01") + } + initialFocus + /> + + + + Your date of birth is used to calculate your age. + + + + )} + /> + ( + + Language + + + + + + + + + + No language found. + + {languages.map((language) => ( + { + form.setValue("language", language.value); + }} + > + + {language.label} + + ))} + + + + + + This is the language that will be used in the dashboard. + + + + )} + /> + + + + ); +} diff --git a/www/frontend/helpar/src/components/forms/EnterpriseProfileForm.tsx b/www/frontend/helpar/src/components/forms/EnterpriseProfileForm.tsx new file mode 100644 index 0000000..32423a2 --- /dev/null +++ b/www/frontend/helpar/src/components/forms/EnterpriseProfileForm.tsx @@ -0,0 +1,186 @@ +"use client"; + +import { Link } from "react-router-dom"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { useFieldArray, useForm } from "react-hook-form"; +import * as z from "zod"; + +import { cn } from "./../../lib/utils"; +import { Button } from "./../../components/ui/button"; +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "./../../components/ui/form"; +import { Input } from "./../../components/ui/input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "./../../components/ui/select"; +import { Textarea } from "./../../components/ui/textarea"; +import { Toast } from "./../../components/ui/toast"; + +const profileFormSchema = z.object({ + username: z + .string() + .min(2, { + message: "Username must be at least 2 characters.", + }) + .max(30, { + message: "Username must not be longer than 30 characters.", + }), + email: z + .string({ + required_error: "Please select an email to display.", + }) + .email(), + bio: z.string().max(160).min(4), + urls: z + .array( + z.object({ + value: z.string().url({ message: "Please enter a valid URL." }), + }), + ) + .optional(), +}); + +type ProfileFormValues = z.infer; + +// This can come from your database or API. +const defaultValues: Partial = { + bio: "I own a computer.", + urls: [ + { value: "https://shadcn.com" }, + { value: "http://twitter.com/shadcn" }, + ], +}; + +export function EnterpriseProfileForm() { + const form = useForm({ + resolver: zodResolver(profileFormSchema), + defaultValues, + mode: "onChange", + }); + + const { fields, append } = useFieldArray({ + name: "urls", + control: form.control, + }); + + function onSubmit(data: ProfileFormValues) { + Toast({ + title: "You submitted the following values:", + }); + } + + return ( +
+ + ( + + Username + + + + + This is your public display name. It can be your real name or a + pseudonym. You can only change this once every 30 days. + + + + )} + /> + ( + + Email + + + You can manage verified email addresses in your{" "} + email settings. + + + + )} + /> + ( + + Bio + +