Compare commits
63 Commits
media-quer
...
adjust-url
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
39c6957e2f | ||
|
|
4f44af5180 | ||
|
|
a341741ac6 | ||
|
|
c387798be7 | ||
|
|
cb476c38d7 | ||
|
|
f086ef98ff | ||
|
|
2ab6dd0d6a | ||
|
|
68df446580 | ||
|
|
3fc2c28b43 | ||
|
|
c4b8022432 | ||
|
|
cea031334e | ||
|
|
c4371f7886 | ||
|
|
8b2db08db1 | ||
|
|
f52f7dc89f | ||
|
|
3eab5d07ff | ||
|
|
83ac4b7c14 | ||
|
|
5eca679f54 | ||
|
|
f4e6953046 | ||
|
|
8c1d214897 | ||
|
|
71443f0abc | ||
|
|
d89970fcac | ||
|
|
6b07d51cb1 | ||
|
|
3c282c589c | ||
|
|
ec7912d1ee | ||
|
|
3c82537378 | ||
|
|
93424823e6 | ||
|
|
31940eaa78 | ||
|
|
9b1a4cc0e2 | ||
|
|
2ca10ea340 | ||
|
|
9b0fc50ddc | ||
|
|
0f770742ef | ||
|
|
06d7cd4169 | ||
|
|
8e0033dc4d | ||
|
|
cde9f52f52 | ||
|
|
0920985a74 | ||
|
|
9d2a0678be | ||
|
|
1418e7f97c | ||
|
|
c0aaaedaf1 | ||
|
|
3a52e7afb3 | ||
|
|
33ec729631 | ||
|
|
0d7bc65995 | ||
|
|
05453895b9 | ||
|
|
14ed0a23a6 | ||
|
|
40ed4f6afd | ||
|
|
26324150d2 | ||
|
|
b0572451d3 | ||
|
|
d9c3bd1db5 | ||
|
|
615dd1dddc | ||
|
|
288092e4b4 | ||
|
|
69a2112df9 | ||
|
|
4ba91c7d9b | ||
|
|
f9fa7def2b | ||
|
|
7b45c5b2ab | ||
|
|
0fb4dcd7d1 | ||
|
|
d3530deb46 | ||
|
|
aadcdc628e | ||
|
|
2d4fcefdcf | ||
|
|
81303573fd | ||
|
|
76fadf05d9 | ||
|
|
b9e3d8ae82 | ||
|
|
6c5ffc2340 | ||
|
|
e32aa4b6aa | ||
|
|
682c168250 |
@@ -1,9 +0,0 @@
|
|||||||
name: check
|
|
||||||
on: [push]
|
|
||||||
jobs:
|
|
||||||
check:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
# actions/checkout v4.2.2, 2026-02-22
|
|
||||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
|
|
||||||
- run: docker build .
|
|
||||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -22,6 +22,8 @@ node_modules/
|
|||||||
|
|
||||||
# Go build artifacts
|
# Go build artifacts
|
||||||
backend/bin/
|
backend/bin/
|
||||||
|
backend/api
|
||||||
|
backend/poller
|
||||||
*.exe
|
*.exe
|
||||||
*.exe~
|
*.exe~
|
||||||
*.dll
|
*.dll
|
||||||
|
|||||||
3
LICENSE
3
LICENSE
@@ -2,8 +2,7 @@ MIT License
|
|||||||
|
|
||||||
Copyright (c) 2026 Steven Jannette
|
Copyright (c) 2026 Steven Jannette
|
||||||
|
|
||||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal
|
||||||
of this software and associated documentation files (the "Software"), to deal
|
|
||||||
in the Software without restriction, including without limitation the rights
|
in the Software without restriction, including without limitation the rights
|
||||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
copies of the Software, and to permit persons to whom the Software is
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
|||||||
3
LLM_DEV_PROMPTS/.dockerignore
Normal file
3
LLM_DEV_PROMPTS/.dockerignore
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
.git
|
||||||
|
node_modules
|
||||||
|
.DS_Store
|
||||||
26
LLM_DEV_PROMPTS/.gitignore
vendored
Normal file
26
LLM_DEV_PROMPTS/.gitignore
vendored
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Editors
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*~
|
||||||
|
*.bak
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
*.sublime-*
|
||||||
|
|
||||||
|
# Node
|
||||||
|
node_modules/
|
||||||
|
|
||||||
|
# Environment / secrets
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
*.pem
|
||||||
|
*.key
|
||||||
|
|
||||||
|
# Prompts
|
||||||
|
prompts/
|
||||||
|
*prompts
|
||||||
|
prompts*
|
||||||
1561
LLM_DEV_PROMPTS/ANGULAR PROJECTS/ANGULAR_BEST_PRACTICES.md
Normal file
1561
LLM_DEV_PROMPTS/ANGULAR PROJECTS/ANGULAR_BEST_PRACTICES.md
Normal file
File diff suppressed because it is too large
Load Diff
47
LLM_DEV_PROMPTS/ANGULAR PROJECTS/CODE_STYLEGUIDE_ANGULAR.md
Normal file
47
LLM_DEV_PROMPTS/ANGULAR PROJECTS/CODE_STYLEGUIDE_ANGULAR.md
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
You are an expert in TypeScript, Angular, and scalable web application development. You write functional, maintainable, performant, and accessible code following Angular and TypeScript best practices.
|
||||||
|
|
||||||
|
## TypeScript Best Practices
|
||||||
|
1. Use strict type checking
|
||||||
|
2. Prefer type inference when the type is obvious
|
||||||
|
3. Avoid the `any` type; use `unknown` when type is uncertain
|
||||||
|
|
||||||
|
## Angular Best Practices
|
||||||
|
1. Always use standalone components over NgModules
|
||||||
|
2. Must NOT set `standalone: true` inside Angular decorators. It's the default in Angular v20+.
|
||||||
|
3. Use signals for state management
|
||||||
|
4. Implement lazy loading for feature routes
|
||||||
|
5. Do NOT use the `@HostBinding` and `@HostListener` decorators. Put host bindings inside the `host` object of the `@Component` or `@Directive` decorator instead
|
||||||
|
6. Use `NgOptimizedImage` for all static images.
|
||||||
|
7. Note: `NgOptimizedImage` does not work for inline base64 images.
|
||||||
|
|
||||||
|
## Accessibility Requirements
|
||||||
|
1. It MUST pass all AXE checks.
|
||||||
|
2. It MUST follow all WCAG AA minimums, including focus management, color contrast, and ARIA attributes.
|
||||||
|
|
||||||
|
### Components
|
||||||
|
1. Keep components small and focused on a single responsibility
|
||||||
|
2. Use `input()` and `output()` functions instead of decorators
|
||||||
|
3. Use `computed()` for derived state
|
||||||
|
4. Set `changeDetection: ChangeDetectionStrategy.OnPush` in `@Component` decorator
|
||||||
|
5. Prefer inline templates for small components
|
||||||
|
6. Prefer Reactive forms instead of Template-driven ones
|
||||||
|
7. Do NOT use `ngClass`, use `class` bindings instead
|
||||||
|
8. Do NOT use `ngStyle`, use `style` bindings instead
|
||||||
|
9. When using external templates/styles, use paths relative to the component TS file.
|
||||||
|
|
||||||
|
## State Management
|
||||||
|
1. Use signals for local component state
|
||||||
|
2. Use `computed()` for derived state
|
||||||
|
3. Keep state transformations pure and predictable
|
||||||
|
4. Do NOT use `mutate` on signals, use `update` or `set` instead
|
||||||
|
|
||||||
|
## Templates
|
||||||
|
1. Keep templates simple and avoid complex logic
|
||||||
|
2. Use native control flow (`@if`, `@for`, `@switch`) instead of `*ngIf`, `*ngFor`, `*ngSwitch`
|
||||||
|
3. Use the async pipe to handle observables
|
||||||
|
4. Do not assume globals like (`new Date()`) are available.
|
||||||
|
|
||||||
|
## Services
|
||||||
|
1. Design services around a single responsibility
|
||||||
|
2. Use the `providedIn: 'root'` option for singleton services
|
||||||
|
3. Use the `inject()` function instead of constructor injection
|
||||||
79
LLM_DEV_PROMPTS/A_EXISTING_REPO_CHECKLIST.md
Normal file
79
LLM_DEV_PROMPTS/A_EXISTING_REPO_CHECKLIST.md
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
---
|
||||||
|
title: Existing Repo Checklist
|
||||||
|
last_modified: 2026-02-22
|
||||||
|
---
|
||||||
|
|
||||||
|
Use this checklist when starting work in a repo that may not conform to our repo policies.
|
||||||
|
|
||||||
|
Plan first. Structure work into discreet tasks, according to well-defined acceptance critera. Work on a feature branch for each work item.
|
||||||
|
|
||||||
|
**Always check your work** and fix gaps between it and the policies and acceptance creiteria before proceeding with the next task.
|
||||||
|
|
||||||
|
# Formatting (do this first)
|
||||||
|
|
||||||
|
- [ ] If the repo has never been formatted to our standards, run `make fmt` and
|
||||||
|
commit the result as a standalone branch/PR before any other
|
||||||
|
changes. Formatting diffs can be large and should not be mixed with
|
||||||
|
functional changes.
|
||||||
|
|
||||||
|
# Required Files
|
||||||
|
|
||||||
|
- [ ] `README.md` exists with all required sections (Description, Getting
|
||||||
|
Started, Rationale, Design, TODO, License, Author)
|
||||||
|
- [ ] `LICENSE` file exists and matches the README
|
||||||
|
- [ ] `REPO_POLICIES.md` exists and version date is current — fetch from
|
||||||
|
`https://github.com/kjannette/LLM_DEV_PROMPTS/blob/master/REPO_POLICIES.md`
|
||||||
|
- [ ] `.gitignore` is comprehensive (OS, editor, language artifacts, secrets) —
|
||||||
|
fetch from `https://github.com/kjannette/LLM_DEV_PROMPTS/blob/master/.gitignore`
|
||||||
|
if missing
|
||||||
|
- [ ] `Dockerfile` and `.dockerignore` exist; Dockerfile runs `make check` as a
|
||||||
|
build step — fetch `.dockerignore` from
|
||||||
|
`https://github.com/kjannette/LLM_DEV_PROMPTS/blob/master/.dockerignore`
|
||||||
|
- [ ] Language-specific config:
|
||||||
|
- [ ] Go: `go.mod`, `go.sum`, `.golangci.yml` (fetch from
|
||||||
|
`https://github.com/kjannette/LLM_DEV_PROMPTS/raw/branch/main/.golangci.yml`)
|
||||||
|
- [ ] JS: `package.json`, `yarn.lock`, `.prettierrc`, `.prettierignore`
|
||||||
|
(fetch from
|
||||||
|
`https://github.com/kjannette/LLM_DEV_PROMPTS/raw/branch/main/.prettierrc` and
|
||||||
|
`https://github.com/kjannette/LLM_DEV_PROMPTS/raw/branch/main/.prettierignore`)
|
||||||
|
- [ ] Python: `pyproject.toml`
|
||||||
|
- [ ] Docs/writing: `.prettierrc`, `.prettierignore` (same URLs as above)
|
||||||
|
|
||||||
|
# Makefile
|
||||||
|
|
||||||
|
- [ ] `Makefile` exists in root — reference
|
||||||
|
`https://github.com/kjannette/LLM_DEV_PROMPTS/blob/master/Makefile`
|
||||||
|
- [ ] Has targets: `test`, `lint`, `fmt`, `fmt-check`, `check`, `docker`,
|
||||||
|
`hooks`
|
||||||
|
- [ ] `make check` does not modify any files in the repo
|
||||||
|
- [ ] `make test` has a 30-second timeout
|
||||||
|
- [ ] `make test` runs real tests, not a no-op (at minimum, import/compile
|
||||||
|
check)
|
||||||
|
- [ ] `make check` passes on current branch
|
||||||
|
|
||||||
|
# Formatting
|
||||||
|
|
||||||
|
- [ ] Platform-standard formatter is configured (`black`, `prettier`, `go fmt`)
|
||||||
|
- [ ] Default formatter config, only exception: four-space indents (except Go)
|
||||||
|
- [ ] All files pass `make fmt-check`
|
||||||
|
|
||||||
|
# Git Hygiene
|
||||||
|
|
||||||
|
- [ ] Pre-commit hook is installed (`make hooks`)
|
||||||
|
- [ ] No secrets in the repo (`.env`, keys, credentials)
|
||||||
|
- [ ] No mutable references in Dockerfiles or scripts (tags, `@latest`) — all
|
||||||
|
pinned by cryptographic hash with version/date comment
|
||||||
|
- [ ] Using `yarn`, not `npm` (JS projects)
|
||||||
|
|
||||||
|
# Directory Structure
|
||||||
|
|
||||||
|
- [ ] No unnecessary files in repo root
|
||||||
|
- [ ] Files organized into canonical subdirectories (`bin/`, `cmd/`, `docs/`,
|
||||||
|
`internal/`, `static/`, etc.)
|
||||||
|
- [ ] Go migrations in `internal/db/migrations/` and embedded in binary
|
||||||
|
|
||||||
|
# Final
|
||||||
|
|
||||||
|
- [ ] `make check` passes
|
||||||
|
- [ ] `docker build` succeeds
|
||||||
|
- [ ] Commit and merge fixes before starting your actual task
|
||||||
88
LLM_DEV_PROMPTS/B_NEW_REPO_CHECKLIST.md
Normal file
88
LLM_DEV_PROMPTS/B_NEW_REPO_CHECKLIST.md
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
---
|
||||||
|
title: New Repo Checklist
|
||||||
|
last_modified: 2026-02-22
|
||||||
|
---
|
||||||
|
|
||||||
|
Use this checklist when creating a new repository from scratch. Follow the steps
|
||||||
|
in order. Full policies are at
|
||||||
|
`https://github.com/kjannette/LLM_DEV_PROMPTS/raw/branch/main/prompts/REPO_POLICIES.md`.
|
||||||
|
|
||||||
|
Template files can be fetched from:
|
||||||
|
`https://github.com/kjannette/LLM_DEV_PROMPTS/raw/branch/main/<path>`
|
||||||
|
|
||||||
|
# 1. Initialize
|
||||||
|
|
||||||
|
- [ ] `git init`
|
||||||
|
- [ ] Ask the user for the license (MIT, GPL, or WTFPL)
|
||||||
|
|
||||||
|
# 2. First Commit (README only)
|
||||||
|
|
||||||
|
- [ ] Create `README.md` with all required sections:
|
||||||
|
- [ ] **Description**: name, purpose, category, license, author
|
||||||
|
- [ ] **Getting Started**: copy-pasteable code block
|
||||||
|
- [ ] **Rationale**: why does this exist?
|
||||||
|
- [ ] **Design**: how is it structured?
|
||||||
|
- [ ] **TODO**: initial task list
|
||||||
|
- [ ] **License**: matches chosen license
|
||||||
|
- [ ] **Author**: [@sjdev](https://sjdev.co)
|
||||||
|
- [ ] `git add README.md && git commit`
|
||||||
|
|
||||||
|
# 3. Scaffolding (feature branch)
|
||||||
|
|
||||||
|
- [ ] `git checkout -b initial-scaffolding`
|
||||||
|
|
||||||
|
## Fetch Template Files
|
||||||
|
|
||||||
|
- [ ] `.gitignore` — fetch from
|
||||||
|
`https://github.com/kjannette/LLM_DEV_PROMPTS/raw/branch/main/.gitignore`, extend for
|
||||||
|
language-specific artifacts
|
||||||
|
- [ ] `.editorconfig` — fetch from
|
||||||
|
`https://github.com/kjannette/LLM_DEV_PROMPTS/raw/branch/main/.editorconfig`
|
||||||
|
- [ ] `Makefile` — fetch from
|
||||||
|
`https://github.com/kjannette/LLM_DEV_PROMPTS/raw/branch/main/Makefile`, adapt
|
||||||
|
targets for the project's language and tools
|
||||||
|
- [ ] For JS/docs repos: `.prettierrc` and `.prettierignore` — fetch from
|
||||||
|
`https://github.com/kjannette/LLM_DEV_PROMPTS/raw/branch/main/.prettierrc` and
|
||||||
|
`https://github.com/kjannette/LLM_DEV_PROMPTS/raw/branch/main/.prettierignore`
|
||||||
|
|
||||||
|
## Create Project Files
|
||||||
|
|
||||||
|
- [ ] `LICENSE` file matching the chosen license
|
||||||
|
- [ ] `REPO_POLICIES.md` — fetch from
|
||||||
|
`https://github.com/kjannette/LLM_DEV_PROMPTS/raw/branch/main/prompts/REPO_POLICIES.md`
|
||||||
|
- [ ] `Dockerfile` and `.dockerignore` — fetch `.dockerignore` from
|
||||||
|
`https://github.com/kjannette/LLM_DEV_PROMPTS/raw/branch/main/.dockerignore`
|
||||||
|
- All Dockerfiles must run `make check` as a build step
|
||||||
|
- Server: also builds and runs the application
|
||||||
|
- Non-server: brings up dev environment and runs `make check`
|
||||||
|
- Image pinned by sha256 hash with version/date comment
|
||||||
|
- [ ] Language-specific:
|
||||||
|
- [ ] Go: `go mod init sjdev.co/go/<name>`, `.golangci.yml`
|
||||||
|
- [ ] JS: `npm init`, `npm i --dev prettier`
|
||||||
|
- [ ] Python: `pyproject.toml`
|
||||||
|
|
||||||
|
## Configure Makefile
|
||||||
|
|
||||||
|
- [ ] `make test` — runs real tests, not a no-op (30-second timeout)
|
||||||
|
- [ ] `make lint` — runs linter
|
||||||
|
- [ ] `make fmt` — formats code (writes)
|
||||||
|
- [ ] `make fmt-check` — checks formatting (read-only)
|
||||||
|
- [ ] `make check` — prereqs: `test`, `lint`, `fmt-check`; must not modify files
|
||||||
|
- [ ] `make docker` — builds Docker image
|
||||||
|
- [ ] `make hooks` — installs pre-commit hook
|
||||||
|
|
||||||
|
# 4. Verify
|
||||||
|
|
||||||
|
- [ ] `make check` passes
|
||||||
|
- [ ] `make docker` succeeds
|
||||||
|
- [ ] No exposed secrets in repo
|
||||||
|
- [ ] No mutable image/package references
|
||||||
|
- [ ] No unnecessary files in repo root
|
||||||
|
- [ ] All dates written as YYYY-MM-DD
|
||||||
|
|
||||||
|
# 5. Merge and Set Up
|
||||||
|
|
||||||
|
- [ ] Commit, merge to `main`
|
||||||
|
- [ ] `make hooks` to install pre-commit hook
|
||||||
|
- [ ] Add remote and push
|
||||||
|
- [ ] Verify `main` passes `make check`
|
||||||
76
LLM_DEV_PROMPTS/C_GENERAL_CODE_STYLEGUIDE.md
Normal file
76
LLM_DEV_PROMPTS/C_GENERAL_CODE_STYLEGUIDE.md
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
---
|
||||||
|
title: Code Styleguide
|
||||||
|
last_modified: 2026-02-22
|
||||||
|
---
|
||||||
|
|
||||||
|
# All
|
||||||
|
|
||||||
|
1. Every repo must have a `Makefile` and a `Dockerfile`. See
|
||||||
|
[Repository Policies](https://github.com/kjannette/LLM_DEV_PROMPTS/raw/branch/main/prompts/REPO_POLICIES.md)
|
||||||
|
for required targets and conventions.
|
||||||
|
|
||||||
|
2. Credentials and/or secrets should never be committed to any repository, even private ones. Store secrets in environment variables, and if they are absolutely required, check on startup to make sure they are set/non-default and complain loudly if not. Exception, sometimes: public keys. (Public keys can still sometimes be secrets for operational security reasons.)
|
||||||
|
|
||||||
|
3. KISS & DRY: Keep it simple, stupid (KISS) and Don't Repeat Yourself (DRY) to minimize complexity and technical debt.
|
||||||
|
|
||||||
|
4. Composition over Inheritance: Prefer combining simple objects to build complex ones rather than creating deep class hierarchies.
|
||||||
|
|
||||||
|
5. Avoid nesting `if` statements. If you have more than one level of nesting,
|
||||||
|
consider inverting the condition and using `return` to exit early.
|
||||||
|
|
||||||
|
6. Almost all services/servers should accept their configuration via environment
|
||||||
|
variables. Only go full config file if absolutely necessary.
|
||||||
|
|
||||||
|
7. For services/servers, log JSON to stdout. This makes it easier to parse and
|
||||||
|
aggregate logs when run under `docker`. Use structured logging whenever
|
||||||
|
possible. You may detect if the output is a terminal and pretty-print the
|
||||||
|
logs in that case.
|
||||||
|
|
||||||
|
8. Debug mode is enabled by setting the environment variable `DEBUG` to a
|
||||||
|
non-empty string. This should enable verbose logging and such. It will never
|
||||||
|
be enabled in prod.
|
||||||
|
|
||||||
|
9. For services/servers, make a healthcheck available at
|
||||||
|
`/.well-known/healthcheck`. The response must have a
|
||||||
|
`Content-Type: application/json` header and return a JSON object containing
|
||||||
|
the service's name, uptime, and a key of `"status"` with a value of `"ok"`.
|
||||||
|
Return a 200 for healthy, 5xx for unhealthy.
|
||||||
|
|
||||||
|
10. If possible, for services/servers, include a /metrics endpoint that returns
|
||||||
|
Prometheus-formatted metrics. This is not required for all services, but is a
|
||||||
|
nice-to-have.
|
||||||
|
|
||||||
|
# Bash / Shell
|
||||||
|
|
||||||
|
1. Use `[[` instead of `[` for conditionals.
|
||||||
|
|
||||||
|
2. Use `$( )` instead of backticks.
|
||||||
|
|
||||||
|
3. Use `#!/usr/bin/env bash` as the shebang line. This allows the script to be
|
||||||
|
run on systems where `bash` is not in `/bin`.
|
||||||
|
|
||||||
|
4. Use `set -euo pipefail` at the top of every script. This will cause the
|
||||||
|
script to exit if any command fails, or if a variable is used before it is set.
|
||||||
|
|
||||||
|
5. Use `pv` for progress bars when piping data through a command.
|
||||||
|
|
||||||
|
6. Put all code in functions, even a main function. Define all functions then
|
||||||
|
call main at the bottom of the file.
|
||||||
|
|
||||||
|
# Docker Containers (for services)
|
||||||
|
|
||||||
|
1. Use `runit` with `runsvinit` as the entrypoint for all containers. This
|
||||||
|
allows for easy service management and logging. In startup scripts
|
||||||
|
(`/etc/service/*/run`) in the container, put a `sleep 1` at the top of the
|
||||||
|
script to avoid spiking the cpu in the case of a fast-exiting process (such
|
||||||
|
as in an error condition). This also limits the maximum number of error
|
||||||
|
messages in logs to 86400/day.
|
||||||
|
|
||||||
|
# Author
|
||||||
|
|
||||||
|
[@sjdev](https://sjdev.co)
|
||||||
|
<[sj@sjdev.co(mailto:sj@sjdev.berlin)>
|
||||||
|
|
||||||
|
# License
|
||||||
|
|
||||||
|
MIT. See [LICENSE](../LICENSE).
|
||||||
@@ -6,12 +6,12 @@ last_modified: 2026-02-22
|
|||||||
This document covers repository structure, tooling, and workflow standards. Code
|
This document covers repository structure, tooling, and workflow standards. Code
|
||||||
style conventions are in separate documents:
|
style conventions are in separate documents:
|
||||||
|
|
||||||
- [Code Styleguide](<link>)
|
- [Code Styleguide](https://github.com/kjannette/LLM_DEV_PROMPTS/raw/branch/main/prompts/CODE_STYLEGUIDE.md)
|
||||||
(general, bash, Docker)
|
(general, bash, Docker)
|
||||||
- [Go](<link>)
|
- [Go](https://github.com/kjannette/LLM_DEV_PROMPTS/raw/branch/main/prompts/CODE_STYLEGUIDE_GO.md)
|
||||||
- [JavaScript](<link>)
|
- [JavaScript](https://github.com/kjannette/LLM_DEV_PROMPTS/raw/branch/main/prompts/CODE_STYLEGUIDE_JS.md)
|
||||||
- [Python](<link>)
|
- [Python](https://github.com/kjannette/LLM_DEV_PROMPTS/raw/branch/main/prompts/CODE_STYLEGUIDE_PYTHON.md)
|
||||||
- [Go HTTP Server Conventions](<link>)
|
- [Go HTTP Server Conventions](https://github.com/kjannette/LLM_DEV_PROMPTS/raw/branch/main/prompts/GO_HTTP_SERVER_CONVENTIONS.md)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -37,7 +37,7 @@ style conventions are in separate documents:
|
|||||||
`make test`, `make lint`, `make fmt` (writes), `make fmt-check` (read-only),
|
`make test`, `make lint`, `make fmt` (writes), `make fmt-check` (read-only),
|
||||||
`make check` (prereqs: `test`, `lint`, `fmt-check`), `make docker`, and
|
`make check` (prereqs: `test`, `lint`, `fmt-check`), `make docker`, and
|
||||||
`make hooks` (installs pre-commit hook). A model Makefile is at
|
`make hooks` (installs pre-commit hook). A model Makefile is at
|
||||||
`(<link>)`.
|
`https://github.com/kjannette/LLM_DEV_PROMPTS/raw/branch/main/Makefile`.
|
||||||
|
|
||||||
- Always use Makefile targets (`make fmt`, `make test`, `make lint`, etc.)
|
- Always use Makefile targets (`make fmt`, `make test`, `make lint`, etc.)
|
||||||
instead of invoking the underlying tools directly. The Makefile is the single
|
instead of invoking the underlying tools directly. The Makefile is the single
|
||||||
@@ -59,10 +59,6 @@ style conventions are in separate documents:
|
|||||||
`make check`. For server repos, `make check` should run as an early build
|
`make check`. For server repos, `make check` should run as an early build
|
||||||
stage before the final image is assembled.
|
stage before the final image is assembled.
|
||||||
|
|
||||||
- Every repo should have a Gitea Actions workflow (`.gitea/workflows/`) that
|
|
||||||
runs `docker build .` on push. Since the Dockerfile already runs `make check`,
|
|
||||||
a successful build implies all checks pass.
|
|
||||||
|
|
||||||
- Use platform-standard formatters: `black` for Python, `prettier` for
|
- Use platform-standard formatters: `black` for Python, `prettier` for
|
||||||
JS/CSS/Markdown/HTML, `go fmt` for Go. Always use default configuration with
|
JS/CSS/Markdown/HTML, `go fmt` for Go. Always use default configuration with
|
||||||
two exceptions: four-space indents (except Go), and `proseWrap: always` for
|
two exceptions: four-space indents (except Go), and `proseWrap: always` for
|
||||||
@@ -95,7 +91,7 @@ style conventions are in separate documents:
|
|||||||
- `.gitignore` should be comprehensive from the start: OS files (`.DS_Store`),
|
- `.gitignore` should be comprehensive from the start: OS files (`.DS_Store`),
|
||||||
editor files (`.swp`, `*~`), language build artifacts, and `node_modules/`.
|
editor files (`.swp`, `*~`), language build artifacts, and `node_modules/`.
|
||||||
Fetch the standard `.gitignore` from
|
Fetch the standard `.gitignore` from
|
||||||
`(<link>) when setting up
|
`https://github.com/kjannette/LLM_DEV_PROMPTS/raw/branch/main/.gitignore` when setting up
|
||||||
a new repo.
|
a new repo.
|
||||||
|
|
||||||
- Never use `git add -A` or `git add .`. Always stage files explicitly by name.
|
- Never use `git add -A` or `git add .`. Always stage files explicitly by name.
|
||||||
@@ -107,7 +103,7 @@ style conventions are in separate documents:
|
|||||||
|
|
||||||
- `.golangci.yml` is standardized and must _NEVER_ be modified by an agent, only
|
- `.golangci.yml` is standardized and must _NEVER_ be modified by an agent, only
|
||||||
manually by the user. Fetch from
|
manually by the user. Fetch from
|
||||||
`(<link>)`.
|
`https://github.com/kjannette/LLM_DEV_PROMPTS/raw/branch/main/.golangci.yml`.
|
||||||
|
|
||||||
- When pinning images or packages by hash, add a comment above the reference
|
- When pinning images or packages by hash, add a comment above the reference
|
||||||
with the version and date (YYYY-MM-DD).
|
with the version and date (YYYY-MM-DD).
|
||||||
@@ -138,7 +134,8 @@ style conventions are in separate documents:
|
|||||||
|
|
||||||
- First commit of a new repo should contain only `README.md`.
|
- First commit of a new repo should contain only `README.md`.
|
||||||
|
|
||||||
- Always run `go mod tidy` before committing.
|
- Go module root: `sjdev.co/go/<name>`. Always run `go mod tidy` before
|
||||||
|
committing.
|
||||||
|
|
||||||
- Use SemVer.
|
- Use SemVer.
|
||||||
|
|
||||||
@@ -174,14 +171,13 @@ style conventions are in separate documents:
|
|||||||
|
|
||||||
- When setting up a new repo, files from the `prompts` repo may be used as
|
- When setting up a new repo, files from the `prompts` repo may be used as
|
||||||
templates. Fetch them from
|
templates. Fetch them from
|
||||||
`(<link>)<path>`.
|
`https://github.com/kjannette/LLM_DEV_PROMPTS/raw/branch/main/<path>`.
|
||||||
|
|
||||||
- New repos must contain at minimum:
|
- New repos must contain:
|
||||||
- `README.md`, `.git`, `.gitignore`, `.editorconfig`
|
- `README.md`, `.git`, `.gitignore`
|
||||||
- `LICENSE`, `REPO_POLICIES.md` (copy from the `prompts` repo)
|
- `LICENSE`, `REPO_POLICIES.md` (copy from the `prompts` repo)
|
||||||
- `Makefile`
|
- `Makefile`
|
||||||
- `Dockerfile`, `.dockerignore`
|
- `Dockerfile`, `.dockerignore`
|
||||||
- `.gitea/workflows/check.yml`
|
|
||||||
- Go: `go.mod`, `go.sum`, `.golangci.yml`
|
- Go: `go.mod`, `go.sum`, `.golangci.yml`
|
||||||
- JS: `package.json`, `yarn.lock`, `.prettierrc`, `.prettierignore`
|
- JS: `package.json`, `.prettierrc`, `.prettierignore`
|
||||||
- Python: `pyproject.toml`
|
- Python: `pyproject.toml`
|
||||||
11
LLM_DEV_PROMPTS/Dockerfile
Normal file
11
LLM_DEV_PROMPTS/Dockerfile
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
# node 22-alpine, 2026-02-22
|
||||||
|
FROM node@sha256:e4bf2a82ad0a4037d28035ae71529873c069b13eb0455466ae0bc13363826e34
|
||||||
|
|
||||||
|
RUN apk add --no-cache make
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
COPY package.json yarn.lock ./
|
||||||
|
RUN yarn install --frozen-lockfile
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
RUN make check
|
||||||
575
LLM_DEV_PROMPTS/GOLANG PROJECTS/CODE_STYLEGUIDE_GO.md
Normal file
575
LLM_DEV_PROMPTS/GOLANG PROJECTS/CODE_STYLEGUIDE_GO.md
Normal file
@@ -0,0 +1,575 @@
|
|||||||
|
---
|
||||||
|
title: Code Styleguide — Go
|
||||||
|
last_modified: 2026-02-22
|
||||||
|
---
|
||||||
|
|
||||||
|
1. Hard wrap long lines at 80 characters or less.
|
||||||
|
|
||||||
|
2. Always `go fmt` code before committing it. The one, rare exception is
|
||||||
|
when committing code that is not yet syntactically valid. This should
|
||||||
|
only happen pre-v0.0.1 or on a non-`main` branch.
|
||||||
|
|
||||||
|
3. Even if you planning to deal with only positive integers, use
|
||||||
|
`int`/`int64` types instead of `uint`/`uint64` types. This is for
|
||||||
|
consistency and compatibility with the standard library.
|
||||||
|
|
||||||
|
4. Any project with more than 3 modules should use the `go.uber.org/fx`
|
||||||
|
injection framework.
|
||||||
|
|
||||||
|
5. Embed the git commit hash into the binary and include it in startup logs and
|
||||||
|
in health check output, to aid correlattion of running instances with their
|
||||||
|
code. Do not include build time or build user, which will make the build
|
||||||
|
nondeterministic.
|
||||||
|
|
||||||
|
Example relevant Makefile sections:
|
||||||
|
|
||||||
|
Given a `main.go` like:
|
||||||
|
|
||||||
|
```go
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
Version string
|
||||||
|
Buildarch string
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
fmt.Printf("Version: %s\n", Version)
|
||||||
|
fmt.Printf("Buildarch: %s\n", Buildarch)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```make
|
||||||
|
VERSION := $(shell git describe --always --dirty)
|
||||||
|
BUILDARCH := $(shell uname -m)
|
||||||
|
|
||||||
|
GOLDFLAGS += -X main.Version=$(VERSION)
|
||||||
|
GOLDFLAGS += -X main.Buildarch=$(BUILDARCH)
|
||||||
|
|
||||||
|
# osx can't statically link apparently?!
|
||||||
|
ifeq ($(UNAME_S),Darwin)
|
||||||
|
GOFLAGS := -ldflags "$(GOLDFLAGS)"
|
||||||
|
endif
|
||||||
|
|
||||||
|
ifneq ($(UNAME_S),Darwin)
|
||||||
|
GOFLAGS = -ldflags "-linkmode external -extldflags -static $(GOLDFLAGS)"
|
||||||
|
endif
|
||||||
|
|
||||||
|
./httpd: ./pkg/*/*.go ./internal/*/*.go cmd/httpd/*.go
|
||||||
|
go build -o $@ $(GOFLAGS) ./cmd/httpd/*.go
|
||||||
|
```
|
||||||
|
6. Use `log/slog` for structured logging. Import `sjdev.co/go/simplelog`
|
||||||
|
for sensible defaults. Example:
|
||||||
|
|
||||||
|
```go
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log/slog"
|
||||||
|
_ "sjdev.co/go/simplelog"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
slog.Info("Starting up")
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
7. Commit at least a single test file to check compilation. The test file can
|
||||||
|
be empty, but should exist. This ensuress that `go test ./...` will
|
||||||
|
always function as a syntax check.
|
||||||
|
|
||||||
|
8. When fixing a specific bug, write a test that reproduces it, before
|
||||||
|
fixing it. This will fix the experience of discovering the bug and the fix into
|
||||||
|
the repo history.
|
||||||
|
|
||||||
|
9. For anything beyond a simple script or tool, or anything that is going to
|
||||||
|
run in any sort of "production" anywhere, make sure it passes
|
||||||
|
`golangci-lint`.
|
||||||
|
|
||||||
|
10. Write a `Dockerfile` for every repo, even if it only runs the tests and
|
||||||
|
linting. `docker build .` should always make sure that the code is in an
|
||||||
|
able-to-be-compiled state, linted, and any tests run. The Docker build
|
||||||
|
should fail if linting doesn't pass.
|
||||||
|
|
||||||
|
11. Every repo must have a `Makefile`. See
|
||||||
|
[Repository Policies](https://github.com/kjannette/LLM_DEV_PROMPTS/blob/master/REPO_POLICIES.md)
|
||||||
|
for required targets and conventions.
|
||||||
|
|
||||||
|
12. If you are writing a single-module library, `.go` files are permissible in the repo
|
||||||
|
root.
|
||||||
|
|
||||||
|
13. If you are writing a multi-module project, put all `.go` files in a `pkg/`
|
||||||
|
or `internal/` subdirectory. `internal/` is for modules used only by the
|
||||||
|
current repo, and `pkg/` is for modules that can be consumed externally.
|
||||||
|
|
||||||
|
14. Binaries go in `cmd/` directories. Each binary should have its own
|
||||||
|
directory. This is to keep the root clean and to make it easier to distinguish a
|
||||||
|
library from a binary. Only package `main` files should be in
|
||||||
|
`cmd/*` directories.
|
||||||
|
|
||||||
|
15. Keep the `main()` function as small as possible.
|
||||||
|
|
||||||
|
16. Keep the `main` package as small as possible. Move as much code as is
|
||||||
|
feasible to a library package, even if it's an internal one. `main` is
|
||||||
|
an entrypoint to the code, not a place for implementations. Exception:
|
||||||
|
single-file scripts.
|
||||||
|
|
||||||
|
17. HTTP HandleFuncs should be returned from methods or functions that need to
|
||||||
|
handle HTTP requests. Don't use methods or your top level functions as
|
||||||
|
handlers.
|
||||||
|
|
||||||
|
18. Provide a .gitignore file that ignores at least `*.log`, `*.out`, and
|
||||||
|
`*.test` files, as well as any binaries.
|
||||||
|
|
||||||
|
19. Constructors should be called `New()` whenever possible. `modulename.New()`
|
||||||
|
works great if you name the packages properly.
|
||||||
|
|
||||||
|
20. Don't make packages too big. Break them up.
|
||||||
|
|
||||||
|
21. Don't make functions or methods too big. Break them up.
|
||||||
|
|
||||||
|
22. Use descriptive names for functions and methods. Don't be afraid to make
|
||||||
|
them a bit long.
|
||||||
|
|
||||||
|
23. Use descriptive names for modules and filenames. Avoid generic names like
|
||||||
|
`server`. `util` is banned.
|
||||||
|
|
||||||
|
24. Constructors should take a Params struct if they need more than 1-2
|
||||||
|
arguments. Positional arguments are an endless source of bugs and should be
|
||||||
|
avoided whenever possible.
|
||||||
|
|
||||||
|
25. Use `context.Context` for all functions that need it. If you don't need it,
|
||||||
|
you can pass `context.Background()`. Anything long-running should get and
|
||||||
|
abide by a Context. A context does not count against your number of function
|
||||||
|
or method arguments for purposes of calculating whether or not you need a
|
||||||
|
Params struct, because the `ctx` is always first.
|
||||||
|
|
||||||
|
26. Contexts are always named `ctx`.
|
||||||
|
|
||||||
|
27. Use `context.WithTimeout` or `context.WithDeadline` for any function that
|
||||||
|
could potentially run for a long time. This is especially true for any
|
||||||
|
function that makes a network call. Sane timeouts are essential.
|
||||||
|
|
||||||
|
28. If a structure/type is only used in one function or method, define it there.
|
||||||
|
If it's used in more than one, define it in the package. Keep it close to
|
||||||
|
its usages. For example:
|
||||||
|
|
||||||
|
```go
|
||||||
|
func (m *Mothership) tvPost() http.HandlerFunc {
|
||||||
|
|
||||||
|
type MSTVRequest struct {
|
||||||
|
URL string `json:"URL"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type MSTVResponse struct {
|
||||||
|
}
|
||||||
|
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
// parse json from request
|
||||||
|
var reqParsed MSTVRequest
|
||||||
|
err = json.NewDecoder(r.Body).Decode(&reqParsed)
|
||||||
|
...
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
SendErrorResponse(w, MSGenericError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Info().Msgf("Casting to %s: %s", tvName, streamURL)
|
||||||
|
SendSuccessResponse(w, &MSTVResponse{})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
29. Avoid global state, especially global variables. If you need to store state
|
||||||
|
that is global to your launch or application instance, use a package
|
||||||
|
`globals` or `appstate` with a struct and a constructor and require it as a
|
||||||
|
dependency in your constructors. This will allow consumers to be more easily
|
||||||
|
testable and will make it easier to reason about the state of your
|
||||||
|
application. Alternately, if your dependency graph allows for it, put it in
|
||||||
|
the main struct/object of your application, but remember that this harms
|
||||||
|
testability.
|
||||||
|
|
||||||
|
30. Package-global "variables" are ok if they are constants, such as static
|
||||||
|
strings or integers or errors.
|
||||||
|
|
||||||
|
31. Whenever possible, avoid hardcoding numbers or values in your code. Use
|
||||||
|
descriptively-named constants instead. Recall the famous SICP quote:
|
||||||
|
"Programs must be written for people to read, and only incidentally for
|
||||||
|
machines to execute." Rather than comments, a descriptive constant name is
|
||||||
|
much cleaner.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
|
||||||
|
```go
|
||||||
|
|
||||||
|
const jsonContentType = "application/json; charset=utf-8"
|
||||||
|
|
||||||
|
func (s *Handlers) respondJSON(w http.ResponseWriter, r *http.Request, data interface{}, status int) {
|
||||||
|
w.WriteHeader(status)
|
||||||
|
w.Header().Set("Content-Type", jsonContentType)
|
||||||
|
...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
32. Define your struct types near their constructors.
|
||||||
|
|
||||||
|
33. Do not create packages whose sole purpose is to hold type definitions.
|
||||||
|
Packages named `types`, `domain`, or `models` that contain only structs and
|
||||||
|
interfaces (with no behavior) are a code smell. Define types alongside the
|
||||||
|
code that uses them. Type-only packages force consuming packages into alias
|
||||||
|
imports and circular-dependency gymnastics, and indicate that the package
|
||||||
|
boundaries were drawn around nouns instead of responsibilities. If multiple
|
||||||
|
packages need the same type, put it in the package that owns the behavior,
|
||||||
|
or in a small, focused interface package — not in a grab-bag types package.
|
||||||
|
|
||||||
|
34. When defining custom string-based types (e.g. `type ImageID string`),
|
||||||
|
implement `fmt.Stringer`. Use `.String()` at SDK and library boundaries
|
||||||
|
instead of `string(v)`. This makes type conversions explicit, grep-able, and
|
||||||
|
consistent across the codebase. Example:
|
||||||
|
|
||||||
|
```go
|
||||||
|
type ContainerID string
|
||||||
|
|
||||||
|
func (id ContainerID) String() string { return string(id) }
|
||||||
|
|
||||||
|
// At the Docker SDK boundary:
|
||||||
|
resp, err := c.docker.ContainerStart(ctx, id.String(), opts)
|
||||||
|
```
|
||||||
|
|
||||||
|
35. Define your interface types near the functions that use them, or if you have
|
||||||
|
multiple conformant types, put the interface(s) in their own file.
|
||||||
|
|
||||||
|
36. Define errors as package-level variables. Use a descriptive name for the
|
||||||
|
error. Use `errors.New` to create the error. If you need to include
|
||||||
|
additional information in the error, use a struct that implements the
|
||||||
|
`error` interface.
|
||||||
|
|
||||||
|
37. Use lowerCamelCase for local function/variable names. Use UpperCamelCase for
|
||||||
|
type names, and exported function/variable names. Use snake_case for JSON
|
||||||
|
keys. Use lowercase for filenames.
|
||||||
|
|
||||||
|
38. Explicitly specify UTC for datetimes unless you have a very good reason not
|
||||||
|
to. Use `time.Now().UTC()` to get the current time in UTC.
|
||||||
|
|
||||||
|
39. String dates should always be ISO8601 formatted. Use `time.Time.Format` with
|
||||||
|
`time.RFC3339` to get the correct format.
|
||||||
|
|
||||||
|
40. Use `time.Time` for all date and time values. Do not use `int64` or `string`
|
||||||
|
for dates or times internally.
|
||||||
|
|
||||||
|
41. When using `time.Time` in a struct, use a pointer to `time.Time` so that you
|
||||||
|
can differentiate between a zero value and a null value.
|
||||||
|
|
||||||
|
42. Use `time.Duration` for all time durations. Do not use `int64` or `string`
|
||||||
|
for durations internally.
|
||||||
|
|
||||||
|
43. When using `time.Duration` in a struct, use a pointer to `time.Duration` so
|
||||||
|
that you can differentiate between a zero value and a null value.
|
||||||
|
|
||||||
|
44. Whenever possible, in argument types and return types, try to use standard
|
||||||
|
library interfaces instead of concrete types. For example, use `io.Reader`
|
||||||
|
instead of `*os.File`. Tailor these to the needs of the specific function or
|
||||||
|
method. Examples:
|
||||||
|
- **`io.Reader`** instead of `*os.File`:
|
||||||
|
- `io.Reader` is a common interface for reading data, which can be
|
||||||
|
implemented by many types, including `*os.File`, `bytes.Buffer`,
|
||||||
|
`strings.Reader`, and network connections like `net.Conn`.
|
||||||
|
|
||||||
|
- **`io.Writer`** instead of `*os.File` or `*bytes.Buffer`:
|
||||||
|
- `io.Writer` is used for writing data. It can be implemented by
|
||||||
|
`*os.File`, `bytes.Buffer`, `net.Conn`, and more.
|
||||||
|
|
||||||
|
- **`io.ReadWriter`** instead of `*os.File`:
|
||||||
|
- `io.ReadWriter` combines `io.Reader` and `io.Writer`. It is often used
|
||||||
|
for types that can both read and write, such as `*os.File` and
|
||||||
|
`net.Conn`.
|
||||||
|
|
||||||
|
- **`io.Closer`** instead of `*os.File` or `*net.Conn`:
|
||||||
|
- `io.Closer` is used for types that need to be closed, including
|
||||||
|
`*os.File`, `net.Conn`, and other resources that require cleanup.
|
||||||
|
|
||||||
|
- **`io.ReadCloser`** instead of `*os.File` or `http.Response.Body`:
|
||||||
|
- `io.ReadCloser` combines `io.Reader` and `io.Closer`, and is commonly
|
||||||
|
used for types like `*os.File` and `http.Response.Body`.
|
||||||
|
|
||||||
|
- **`io.WriteCloser`** instead of `*os.File` or `*gzip.Writer`:
|
||||||
|
- `io.WriteCloser` combines `io.Writer` and `io.Closer`. It is used for
|
||||||
|
types like `*os.File` and `gzip.Writer`.
|
||||||
|
|
||||||
|
- **`io.ReadWriteCloser`** instead of `*os.File` or `*net.TCPConn`:
|
||||||
|
- `io.ReadWriteCloser` combines `io.Reader`, `io.Writer`, and
|
||||||
|
`io.Closer`. Examples include `*os.File` and `net.TCPConn`.
|
||||||
|
|
||||||
|
- **`fmt.Stringer`** instead of implementing a custom `String` method:
|
||||||
|
- `fmt.Stringer` is an interface for types that can convert themselves
|
||||||
|
to a string. Any type that implements the `String() string` method
|
||||||
|
satisfies this interface.
|
||||||
|
|
||||||
|
- **`error`** instead of custom error types:
|
||||||
|
- The `error` interface is used for representing errors. Instead of
|
||||||
|
defining custom error types, you can use the `errors.New` function or
|
||||||
|
the `fmt.Errorf` function to create errors.
|
||||||
|
|
||||||
|
- **`net.Conn`** instead of `*net.TCPConn` or `*net.UDPConn`:
|
||||||
|
- `net.Conn` is a generic network connection interface that can be
|
||||||
|
implemented by TCP, UDP, and other types of network connections.
|
||||||
|
|
||||||
|
- **`http.Handler`** instead of custom HTTP handlers:
|
||||||
|
- `http.Handler` is an interface for handling HTTP requests. Instead of
|
||||||
|
creating custom handler types, you can use types that implement the
|
||||||
|
`ServeHTTP(http.ResponseWriter, *http.Request)` method.
|
||||||
|
|
||||||
|
- **`http.HandlerFunc`** instead of creating a new type:
|
||||||
|
- `http.HandlerFunc` is a type that allows you to use functions as HTTP
|
||||||
|
handlers by implementing the `http.Handler` interface.
|
||||||
|
|
||||||
|
- **`encoding.BinaryMarshaler` and `encoding.BinaryUnmarshaler`** instead of
|
||||||
|
custom marshal/unmarshal methods:
|
||||||
|
- These interfaces are used for binary serialization and
|
||||||
|
deserialization. Implementing these interfaces allows types to be
|
||||||
|
encoded and decoded in a standard way.
|
||||||
|
|
||||||
|
- **`encoding.TextMarshaler` and `encoding.TextUnmarshaler`** instead of
|
||||||
|
custom text marshal/unmarshal methods:
|
||||||
|
- These interfaces are used for text-based serialization and
|
||||||
|
deserialization. They are useful for types that need to be represented
|
||||||
|
as text.
|
||||||
|
|
||||||
|
- **`sort.Interface`** instead of custom sorting logic:
|
||||||
|
- `sort.Interface` is an interface for sorting collections. By
|
||||||
|
implementing the `Len`, `Less`, and `Swap` methods, you can sort any
|
||||||
|
collection using the `sort.Sort` function.
|
||||||
|
|
||||||
|
- **`flag.Value`** instead of custom flag parsing:
|
||||||
|
- `flag.Value` is an interface for defining custom command-line flags.
|
||||||
|
Implementing the `String` and `Set` methods allows you to use custom
|
||||||
|
types with the `flag` package.
|
||||||
|
|
||||||
|
45. Avoid using `panic` in library code. Instead, return errors to allow the
|
||||||
|
caller to handle them. Reserve `panic` for truly exceptional conditions.
|
||||||
|
|
||||||
|
46. Use `defer` to ensure resources are properly cleaned up, such as closing
|
||||||
|
files or network connections. Place `defer` statements immediately after
|
||||||
|
resource acquisition.
|
||||||
|
|
||||||
|
47. When calling a function with `go`, wrap it in an anonymous function to ensure
|
||||||
|
it runs in the new goroutine context:
|
||||||
|
|
||||||
|
Right:
|
||||||
|
|
||||||
|
```go
|
||||||
|
go func() {
|
||||||
|
someFunction(arg1, arg2)
|
||||||
|
}()
|
||||||
|
```
|
||||||
|
|
||||||
|
Wrong:
|
||||||
|
|
||||||
|
```go
|
||||||
|
go someFunction(arg1, arg2)
|
||||||
|
```
|
||||||
|
|
||||||
|
48. Use `iota` to define enumerations in a type-safe way. This ensures that the
|
||||||
|
constants are properly grouped and reduces the risk of errors.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
|
||||||
|
```go
|
||||||
|
|
||||||
|
type HandScore int
|
||||||
|
|
||||||
|
const (
|
||||||
|
ScoreHighCard = HandScore(iota * 100_000_000_000)
|
||||||
|
ScorePair
|
||||||
|
ScoreTwoPair
|
||||||
|
ScoreThreeOfAKind
|
||||||
|
ScoreStraight
|
||||||
|
ScoreFlush
|
||||||
|
ScoreFullHouse
|
||||||
|
ScoreFourOfAKind
|
||||||
|
ScoreStraightFlush
|
||||||
|
ScoreRoyalFlush
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
Example 2:
|
||||||
|
|
||||||
|
```go
|
||||||
|
type ByteSize float64
|
||||||
|
|
||||||
|
const (
|
||||||
|
_ = iota // ignore first value by assigning to blank identifier
|
||||||
|
KB ByteSize = 1 << (10 * iota)
|
||||||
|
MB
|
||||||
|
GB
|
||||||
|
TB
|
||||||
|
PB
|
||||||
|
EB
|
||||||
|
ZB
|
||||||
|
YB
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
49. Do not hardcode large lists. Either isolate lists
|
||||||
|
in their own module/package and write getters, or use a third party
|
||||||
|
library. For example, if you need a list of country codes, you can use
|
||||||
|
[https://github.com/emvi/iso-639-1](https://github.com/emvi/iso-639-1). It is
|
||||||
|
permissible to embed a data file (use `go embed`) in your binary,
|
||||||
|
but make sure you parse it once as a singleton and don't read it from disk
|
||||||
|
every time you need it. Don't use too much memory for this, embedding
|
||||||
|
anything more than perhaps 25MiB (uncompressed) is probably too much.
|
||||||
|
Compress the file before embedding and uncompress during the reading/parsing
|
||||||
|
step.
|
||||||
|
|
||||||
|
50. When storing numeric values that represent a number of units, either include
|
||||||
|
the unit in the variable name (e.g. `uptimeSeconds`, `delayMsec`,
|
||||||
|
`coreTemperatureCelsius`), or use a type alias (that includes the unit
|
||||||
|
name), or use a 3p library such as
|
||||||
|
[github.com/alecthomas/units](https://github.com/alecthomas/units) for
|
||||||
|
SI/IEC byte units, or
|
||||||
|
[github.com/bcicen/go-units](https://github.com/bcicen/go-units) for
|
||||||
|
temperatures (and others). The type system is your friend, use it.
|
||||||
|
|
||||||
|
51. Once you have a working program, run `go mod tidy` to clean up your `go.mod`
|
||||||
|
and `go.sum` files. Tag a v0.0.1 or v1.0.0. Push your `main` branch and
|
||||||
|
tag(s). Subsequent work should happen on branches so that `main` is "always
|
||||||
|
releasable". "Releasable" in this context means that it builds and functions
|
||||||
|
as expected, and that all tests and linting passes.
|
||||||
|
|
||||||
|
# Other Golang Best Practices (Optional)
|
||||||
|
|
||||||
|
1. For any internet-facing http server, set appropriate timeouts and limits to
|
||||||
|
protect against slowloris attacks or huge uploads that can consume server
|
||||||
|
resources without authentication.
|
||||||
|
|
||||||
|
Example to limit request body size:
|
||||||
|
|
||||||
|
```go
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
// Limit the request body to 10MB
|
||||||
|
r.Body = http.MaxBytesReader(w, r.Body, 10<<20)
|
||||||
|
if err := r.ParseForm(); err != nil {
|
||||||
|
http.Error(w, "Request body too large", http.StatusRequestEntityTooLarge)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fmt.Fprintf(w, "Hello, World!")
|
||||||
|
})
|
||||||
|
|
||||||
|
http.ListenAndServe(":8080", nil)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Example to set appropriate timeouts:
|
||||||
|
|
||||||
|
```go
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
server := &http.Server{
|
||||||
|
Addr: ":8080",
|
||||||
|
ReadTimeout: 5 * time.Second,
|
||||||
|
WriteTimeout: 10 * time.Second,
|
||||||
|
Handler: http.DefaultServeMux,
|
||||||
|
}
|
||||||
|
|
||||||
|
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
fmt.Fprintf(w, "Hello, World!")
|
||||||
|
})
|
||||||
|
|
||||||
|
server.ListenAndServe()
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
2. When passing channels to goroutines, use read-only (`<-chan`) or write-only
|
||||||
|
(`chan<-`) channels to communicate the direction of data flow clearly.
|
||||||
|
|
||||||
|
3. Use `io.MultiReader` to concatenate multiple readers and `io.MultiWriter` to
|
||||||
|
duplicate writes to multiple writers. This can simplify the handling of
|
||||||
|
multiple data sources or destinations.
|
||||||
|
|
||||||
|
4. For simple counters and flags, use the `sync/atomic` package to avoid the
|
||||||
|
overhead of mutexes.
|
||||||
|
|
||||||
|
5. When using mutexes, minimize the scope of locking to reduce contention and
|
||||||
|
potential deadlocks. Prefer to lock only the critical sections of code and try
|
||||||
|
to encapsulate it in its own method. Acquire
|
||||||
|
the lock in the first function line, defer release of the lock as the
|
||||||
|
second line, and lines 3-5 should perform the task. Keep it short. Avoid using mutexes in the middle of a function. In short, build atomic functions.
|
||||||
|
|
||||||
|
6. Design types to be immutable, to avoid issues with concurrent access.
|
||||||
|
|
||||||
|
7. Global state can lead to unpredictable behavior and makes the code harder to
|
||||||
|
test. Use dependency injection to manage state.
|
||||||
|
|
||||||
|
8. Avoid using `init` functions unless absolutely necessary. Tthey can lead to
|
||||||
|
unpredictable initialization order and make code harder to understand.
|
||||||
|
|
||||||
|
9. Provide comments for all public interfaces explaining what they do and how
|
||||||
|
they should be used, to help other developers understand the intended use.
|
||||||
|
|
||||||
|
10. Be mindful of resource leaks when using `time.Timer` and `time.Ticker`.
|
||||||
|
Always stop them when they are no longer needed.
|
||||||
|
|
||||||
|
11. Use `sync.Pool` to manage a pool of reusable objects, which can help reduce
|
||||||
|
GC overhead and improve performance in high-throughput scenarios.
|
||||||
|
|
||||||
|
12. Avoid using large buffer sizes for channels. Unbounded channels can lead to
|
||||||
|
memory leaks. Use appropriate buffer sizes based on the application's needs.
|
||||||
|
|
||||||
|
13. Always handle the case where a channel might be closed. This prevents panic
|
||||||
|
and ensures graceful shutdowns.
|
||||||
|
|
||||||
|
14. For small structs, use value receivers to avoid unnecessary heap allocations.
|
||||||
|
Use pointer receivers for large structs or when mutating the receiver.
|
||||||
|
|
||||||
|
15. Only use goroutines when necessary. Excessive goroutines can lead to high
|
||||||
|
memory consumption and increased complexity.
|
||||||
|
|
||||||
|
16. Use `sync.Cond` for more complex synchronization needs that cannot be met
|
||||||
|
with simple mutexes and channels.
|
||||||
|
|
||||||
|
17. Reflection is powerful but should be used sparingly as it can lead to code
|
||||||
|
that is hard to understand and maintain. Prefer type-safe solutions.
|
||||||
|
|
||||||
|
18. Avoid storing large or complex data in context. Context should be used for
|
||||||
|
request-scoped values like deadlines, cancellation signals, and
|
||||||
|
authentication tokens.
|
||||||
|
|
||||||
|
19. Use `runtime.Callers` and `runtime.CallersFrames` to capture stack traces for
|
||||||
|
debugging and logging purposes.
|
||||||
|
|
||||||
|
20. Use the `testing.TB` interface to write helper functions that can be used
|
||||||
|
with both `*testing.T` and `*testing.B`.
|
||||||
|
|
||||||
|
21. Use struct embedding to reuse code across multiple structs. This form of
|
||||||
|
composition simplifies code reuse.
|
||||||
|
|
||||||
|
22. Prefer defining explicit interfaces in your packages rather than relying on
|
||||||
|
implicit interfaces, for clarity.
|
||||||
|
|
||||||
|
# Author
|
||||||
|
|
||||||
|
[@sjdev](https://sjdev.co)
|
||||||
|
<[sj@sjdev.co(mailto:sj@sjdev.berlin)>
|
||||||
|
|
||||||
|
# License
|
||||||
|
|
||||||
|
MIT. See [LICENSE](../LICENSE).
|
||||||
1242
LLM_DEV_PROMPTS/GOLANG PROJECTS/GO_HTTP_SERVER_CONVENTIONS.md
Normal file
1242
LLM_DEV_PROMPTS/GOLANG PROJECTS/GO_HTTP_SERVER_CONVENTIONS.md
Normal file
File diff suppressed because it is too large
Load Diff
21
LLM_DEV_PROMPTS/LICENSE
Normal file
21
LLM_DEV_PROMPTS/LICENSE
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2026 sjdev
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
31
LLM_DEV_PROMPTS/Makefile
Normal file
31
LLM_DEV_PROMPTS/Makefile
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
.PHONY: test lint fmt fmt-check check docker hooks
|
||||||
|
|
||||||
|
# flags are repeated here (also in .prettierrc) so this Makefile works
|
||||||
|
# standalone when copied as a template
|
||||||
|
PRETTIER := yarn run prettier
|
||||||
|
|
||||||
|
test:
|
||||||
|
@echo "No tests defined."
|
||||||
|
|
||||||
|
lint:
|
||||||
|
@echo "Linting markdown files..."
|
||||||
|
@$(PRETTIER) --check '**/*.md' --tab-width 4 --prose-wrap always
|
||||||
|
|
||||||
|
fmt:
|
||||||
|
@$(PRETTIER) --write '**/*.md' --tab-width 4 --prose-wrap always
|
||||||
|
|
||||||
|
fmt-check:
|
||||||
|
@$(PRETTIER) --check '**/*.md' --tab-width 4 --prose-wrap always
|
||||||
|
|
||||||
|
check: test lint fmt-check
|
||||||
|
|
||||||
|
docker:
|
||||||
|
docker build -t prompts .
|
||||||
|
|
||||||
|
hooks:
|
||||||
|
@printf '#!/bin/sh\nset -e\n' > .git/hooks/pre-commit
|
||||||
|
@if [ -f go.mod ]; then \
|
||||||
|
printf 'go mod tidy\ngo fmt ./...\ngit diff --exit-code -- go.mod go.sum || { echo "go mod tidy changed files; please stage and retry"; exit 1; }\n' >> .git/hooks/pre-commit; \
|
||||||
|
fi
|
||||||
|
@printf 'make check\n' >> .git/hooks/pre-commit
|
||||||
|
@chmod +x .git/hooks/pre-commit
|
||||||
81
LLM_DEV_PROMPTS/NODEJS PROJECTS/CODE_STYLEGUIDE_JS.md
Normal file
81
LLM_DEV_PROMPTS/NODEJS PROJECTS/CODE_STYLEGUIDE_JS.md
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
---
|
||||||
|
title: Code Styleguide — JavaScript
|
||||||
|
last_modified: 2026-02-22
|
||||||
|
---
|
||||||
|
|
||||||
|
1. Use `const` for all declarations, unless you need to reassign, then use `let`. Never use
|
||||||
|
`var`.
|
||||||
|
|
||||||
|
2. Indentation: Use 2 spaces. Do not use tabs.
|
||||||
|
|
||||||
|
3. Semicolons shoudl be used at the end of statements.
|
||||||
|
|
||||||
|
4. Quotes: Prefer single quotes for strings, unless working with JSON or needing to separate object strings.
|
||||||
|
|
||||||
|
5. Limit lines to approximately 80 characters for better readability.
|
||||||
|
|
||||||
|
6. Brace Placement: Place the opening brace on the same line as the statement (e.g., if (true) {).
|
||||||
|
|
||||||
|
7. Naming Conventions:
|
||||||
|
|
||||||
|
Variables, properties, and functions use lowerCamelCase.
|
||||||
|
Class names use UpperCamelCase (PascalCase).
|
||||||
|
Constants use UPPERCASE_WITH_UNDERSCORES.
|
||||||
|
|
||||||
|
8. Equality: Always use the strict equality operator (===) over the abstract equality operator (==).
|
||||||
|
|
||||||
|
9. Use npm for package management, avoid using yarn.
|
||||||
|
|
||||||
|
10. Use nvm and install/select the most current LTS Node version.
|
||||||
|
|
||||||
|
11. Use `prettier` for code formatting, with four spaces for indentation.
|
||||||
|
|
||||||
|
12. At a minimum, both `npm run test`/`npm run build` should work (complete the appropriate scripts in `package.json`). However, prefer `make test` and `make build` instead —
|
||||||
|
|
||||||
|
13. The Makefile is authoritative on how to interact with the repo. See
|
||||||
|
[Repository Policies](https://github.com/kjannette/LLM_DEV_PROMPTS/blob/master/REPO_POLICIES.md) for details.
|
||||||
|
|
||||||
|
14. Use UNIX-style newlines (\n), and a newline character as the last character of a file. Windows-style newlines (\r\n) are forbidden inside any Node/JS repository.
|
||||||
|
|
||||||
|
15. Declare one variable per statement:
|
||||||
|
|
||||||
|
**Correct:**
|
||||||
|
|
||||||
|
```
|
||||||
|
const keys = ['foo', 'bar'];
|
||||||
|
const values = [23, 42];
|
||||||
|
const object = {};
|
||||||
|
```
|
||||||
|
**Incorrect:**
|
||||||
|
|
||||||
|
```
|
||||||
|
const keys = ['foo', 'bar'],
|
||||||
|
values = [23, 42],
|
||||||
|
object = {},
|
||||||
|
```
|
||||||
|
|
||||||
|
16. Name closures in order to produce better stack traces, heap and cpu profiles.
|
||||||
|
|
||||||
|
**Correct:**
|
||||||
|
|
||||||
|
```
|
||||||
|
req.on('end', function onEnd() {
|
||||||
|
console.log('winning');
|
||||||
|
});
|
||||||
|
```
|
||||||
|
**Incorrect:**
|
||||||
|
|
||||||
|
```
|
||||||
|
req.on('end', function() {
|
||||||
|
console.log('losing');
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
# Author
|
||||||
|
|
||||||
|
[@sjDev](https://sjdev.co)
|
||||||
|
<[sj@sjdev.co](mailto:sj@sjdev.co)>
|
||||||
|
|
||||||
|
# License
|
||||||
|
|
||||||
|
MIT. See [LICENSE](../LICENSE).
|
||||||
30
LLM_DEV_PROMPTS/NODEJS PROJECTS/NODE_BACKEND_ARCH_BASICS.md
Normal file
30
LLM_DEV_PROMPTS/NODEJS PROJECTS/NODE_BACKEND_ARCH_BASICS.md
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
---
|
||||||
|
title: Node Backend Architectural Basic Principals
|
||||||
|
last_modified: 2026-03-02
|
||||||
|
---
|
||||||
|
|
||||||
|
1. Separate concerns into distinct layers.
|
||||||
|
|
||||||
|
2. Adhering to the above pricinciple makes c ode more extensible, testable and maintainable. Group around functionality, adhering to the following structure on the backend:
|
||||||
|
|
||||||
|
a. **Routes/Controllers:** Handle API endpoints and process the request/response cycle. Controllers should be kept lean, delegating business logic to the service layer.
|
||||||
|
|
||||||
|
b. **Services/Business Logic:** Contain the core application logic and domain rules. This layer orchestrates interactions between other components.
|
||||||
|
|
||||||
|
c. **Models/Data Access:** Interact with the database (using ORMs like Mongoose or Sequelize). Abstract database logic into repositories for reusability.
|
||||||
|
|
||||||
|
d. **Middleware:** Used for cross-cutting concerns such as cors, authentication, logging, and error handling.
|
||||||
|
|
||||||
|
3. Modularity: Break code into the smallest reusable modules that make logical sense. Each discreet class or method should have a single responsibility.
|
||||||
|
|
||||||
|
4. Objects should depend on abstractions, not concretions. High-level modules contain the core business logic or application-specific behavior.
|
||||||
|
Lower-level modules deal with implementation details, such as interacting with a database, file system, or external APIs.
|
||||||
|
|
||||||
|
# Author
|
||||||
|
|
||||||
|
[@sjDev](https://sjdev.co)
|
||||||
|
<[sj@sjdev.co](mailto:sj@sjdev.co)>
|
||||||
|
|
||||||
|
# License
|
||||||
|
|
||||||
|
MIT. See [LICENSE](../LICENSE).
|
||||||
54
LLM_DEV_PROMPTS/PYTHON PROJECTS/CODE_STYLEGUIDE_PYTHON.md
Normal file
54
LLM_DEV_PROMPTS/PYTHON PROJECTS/CODE_STYLEGUIDE_PYTHON.md
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
---
|
||||||
|
title: Code Styleguide — Python
|
||||||
|
last_modified: 2026-02-22
|
||||||
|
---
|
||||||
|
|
||||||
|
1. Standard Project Layout. Use the src/ layout to prevent accidental imports from the root and ensure your project behaves like an installed package:
|
||||||
|
|
||||||
|
my_project/
|
||||||
|
├── pyproject.toml # Modern tool & dependency configuration
|
||||||
|
├── README.md # Instructions for humans
|
||||||
|
├── LICENSE # Usage rights
|
||||||
|
├── .gitignore # Exclude venv/, __pycache__/, .env
|
||||||
|
├── src/
|
||||||
|
│ └── my_project/ # Main package
|
||||||
|
│ ├── __init__.py
|
||||||
|
│ ├── main.py # Minimal entry point logic
|
||||||
|
│ └── core.py # Business logic
|
||||||
|
├── tests/ # Mirror src structure for testing
|
||||||
|
└── docs/ # Technical documentation
|
||||||
|
|
||||||
|
2. Virtual Environments: Always isolate project dependencies using venv or pyenv to avoid conflicts with system-wide packages.
|
||||||
|
|
||||||
|
3. Adhere to PEP 8: Use 4 spaces for indentation (no tabs), limit lines to 79 characters, and use two blank lines between top-level functions.
|
||||||
|
|
||||||
|
4. Put code in functions. If you are writing a script, put the script in a
|
||||||
|
function called `main` and call `main()` at the end of the script using the
|
||||||
|
standard invocation:
|
||||||
|
|
||||||
|
```python
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
|
```
|
||||||
|
5. Keep main.py Boring: The entry point should only orchestrate (load config, start app). Heavy business logic should live in specialized modules.
|
||||||
|
|
||||||
|
6. Config Isolation: Never hardcode secrets or database URLs. Use a .env file with libraries like python-dotenv or Pydantic Settings.
|
||||||
|
|
||||||
|
7. Logging over Printing: Use Python’s built-in logging module to track errors and application state in production.
|
||||||
|
|
||||||
|
8. Test-First: Treat your tests/ directory as first-class code. Use pytest for its powerful features and simple syntax.
|
||||||
|
|
||||||
|
9. Naming Conventions: Use snake_case for functions and variables, PascalCase for classes, and UPPER_CASE for constants.
|
||||||
|
|
||||||
|
10. pyproject.toml: Use this as the single source of truth for project metadata and tool configurations (replaces setup.py).
|
||||||
|
|
||||||
|
11. Modular Design: Group code by domain (e.g., /users, /payments) rather than function (e.g., utils.py) to maintain separation of concerns.
|
||||||
|
|
||||||
|
# Author
|
||||||
|
|
||||||
|
[@sjdev](https://sjdev.co)
|
||||||
|
<[sj@sjdev.co](mailto:sj@sjdev.berlin)>
|
||||||
|
|
||||||
|
# License
|
||||||
|
|
||||||
|
MIT. See [LICENSE](../LICENSE).
|
||||||
139
LLM_DEV_PROMPTS/README.md
Normal file
139
LLM_DEV_PROMPTS/README.md
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
# LLM Development Prompts
|
||||||
|
|
||||||
|
An MIT-licensed collection of LLM prompts by [@sjdev](https://sjdev.co), intended for use in bootstrapping new projects or building out new features in existing codebases.
|
||||||
|
|
||||||
|
The prompts set forth best practices and procedural directives that must be followed in architecture and development. Think of this repo as akin to the Chicago Manual of Style, AP Stylebook (for journalism), or The ALWD Guide to Legal Citation.
|
||||||
|
|
||||||
|
The prompts include 1) general repository development standards and 2) language and framework-specific development directives, intended to be reviewed by the code-building model at the outset work and followed throughout the work’s progress. For example, the Javascript code styleguide sets forth the simple directive: “[u]se const for all declarations, unless you need to reassign, then use let. Never use var.”
|
||||||
|
|
||||||
|
These prompts are a work in progress. I add to them as I work on new projects in new languages and frameworks, and I am also still in the process of memorializing prompts relating to languages and frameworks I have used for many years.
|
||||||
|
|
||||||
|
# Usage - generally
|
||||||
|
|
||||||
|
Imagine the scenario: you, as a developer, are tasked with adding a new feature to an existing codebase, with maximum automation via LLM code generation. At the outset, before adressing the substantive implementation, you would run the following prompt (discussed in greater detail below):
|
||||||
|
|
||||||
|
Read $TD/prompts/REPO_POLICIES.md and $TD/prompts/EXISTING_REPO_CHECKLIST.md, then bring this repo up to those
|
||||||
|
standards. Your scope is repo scaffolding and policy compliance: Makefile, Dockerfile, .dockerignore, .gitignore, .editorconfig, CI
|
||||||
|
workflow, README sections, LICENSE, REPO_POLICIES.md, and any language-specific config files (.golangci.yml, .prettierrc, etc.).
|
||||||
|
|
||||||
|
## Quick Start - optional scripts for cli agent
|
||||||
|
|
||||||
|
### Existing Repo
|
||||||
|
|
||||||
|
Run from within the repo you want to bring up to standard. Clone the prompts repo once, then run both commands in order.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export TD="$(mktemp -d)"
|
||||||
|
git clone --depth 1 https://github.com/kjannette/LLM_DEV_PROMPTS.git "$TD"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Repository structure and policies:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
claude "Read $TD/prompts/REPO_POLICIES.md and
|
||||||
|
$TD/prompts/EXISTING_REPO_CHECKLIST.md, then bring this repo up to those
|
||||||
|
standards. Your scope is repo scaffolding and policy compliance:
|
||||||
|
Makefile, Dockerfile, .dockerignore, .gitignore, .editorconfig, CI
|
||||||
|
workflow, README sections, LICENSE, REPO_POLICIES.md, and any
|
||||||
|
language-specific config files (.golangci.yml, .prettierrc, etc.).
|
||||||
|
You must also run the formatter (make fmt) and fix any linter errors
|
||||||
|
(make lint) so that make check passes — this will touch source code,
|
||||||
|
but do not restructure, refactor, or rewrite any application logic.
|
||||||
|
Follow the policies yourself: work on a feature branch, never git add -A,
|
||||||
|
and make each logical change a separate commit (e.g. one commit for
|
||||||
|
formatting, one for linter fixes, one for README updates, one for each
|
||||||
|
new repo file added, etc.)."
|
||||||
|
```
|
||||||
|
|
||||||
|
**Code style and conventions:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
claude "Read $TD/prompts/CODE_STYLEGUIDE.md and whichever
|
||||||
|
language-specific styleguides in $TD/prompts/ apply to this repo
|
||||||
|
(CODE_STYLEGUIDE_GO.md, CODE_STYLEGUIDE_JS.md, CODE_STYLEGUIDE_PYTHON.md,
|
||||||
|
GO_HTTP_SERVER_CONVENTIONS.md). Then review the application code in this
|
||||||
|
repo and bring it into compliance with those coding standards. Your scope
|
||||||
|
is application code structure and style: naming, patterns, error
|
||||||
|
handling, project layout, and conventions described in the styleguides.
|
||||||
|
Do not modify repo scaffolding (Makefile, Dockerfile, CI workflow,
|
||||||
|
.gitignore, .editorconfig, etc.) — only application code. Work on a
|
||||||
|
feature branch, never git add -A, and make each logical change a
|
||||||
|
separate commit."
|
||||||
|
```
|
||||||
|
|
||||||
|
### New Repo
|
||||||
|
|
||||||
|
Run from inside the directory where you want to create a new repo. Clone the
|
||||||
|
prompts repo once, then run both commands in order.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export TD="$(mktemp -d)"
|
||||||
|
git clone --depth 1 https://github.com/kjannette/LLM_DEV_PROMPTS.git "$TD"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Repository scaffolding:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
claude "Read $TD/prompts/REPO_POLICIES.md and
|
||||||
|
$TD/prompts/NEW_REPO_CHECKLIST.md, then set up this new repo according
|
||||||
|
to those standards. Your scope is repo structure and required files:
|
||||||
|
README.md, LICENSE, REPO_POLICIES.md, Makefile, Dockerfile, .dockerignore,
|
||||||
|
.gitignore, .editorconfig, CI workflow, and language-specific config.
|
||||||
|
Run the formatter (make fmt) and fix any linter errors (make lint) so
|
||||||
|
that make check passes — this will touch source code, but do not
|
||||||
|
restructure, refactor, or rewrite any application logic. Follow the
|
||||||
|
policies yourself: work on a feature branch, never git add -A, and make
|
||||||
|
each logical change a separate commit (e.g. one commit for formatting,
|
||||||
|
one for linter fixes, one for README, one for each new repo file, etc.)."
|
||||||
|
```
|
||||||
|
|
||||||
|
**Code style and conventions:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
claude "Read $TD/prompts/CODE_STYLEGUIDE.md and whichever
|
||||||
|
language-specific styleguides in $TD/prompts/ apply to this repo
|
||||||
|
(CODE_STYLEGUIDE_GO.md, CODE_STYLEGUIDE_JS.md, CODE_STYLEGUIDE_PYTHON.md,
|
||||||
|
GO_HTTP_SERVER_CONVENTIONS.md). Then review the application code in this
|
||||||
|
repo and bring it into compliance with those coding standards. Your scope
|
||||||
|
is application code structure and style: naming, patterns, error
|
||||||
|
handling, project layout, and conventions described in the styleguides.
|
||||||
|
Do not modify repo scaffolding (Makefile, Dockerfile, CI workflow,
|
||||||
|
.gitignore, .editorconfig, etc.) — only application code. Work on a
|
||||||
|
feature branch, never git add -A, and make each logical change a
|
||||||
|
separate commit."
|
||||||
|
```
|
||||||
|
|
||||||
|
## Getting Started
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://github.com/kjannette/LLM_DEV_PROMPTS.git
|
||||||
|
cd prompts
|
||||||
|
```
|
||||||
|
|
||||||
|
Prompts are stored as Markdown files in `prompts/`. Copy or reference them as
|
||||||
|
needed in your projects.
|
||||||
|
|
||||||
|
## Rationale
|
||||||
|
|
||||||
|
LLM prompts, especially development policies, benefit from version control and a
|
||||||
|
single authoritative source. This repo provides a central place to maintain,
|
||||||
|
share, and evolve prompts across projects.
|
||||||
|
|
||||||
|
## Design
|
||||||
|
|
||||||
|
The repository is a collection of Markdown files organized in the `prompts/`
|
||||||
|
subdirectory. Each file contains one or more related prompts or policy
|
||||||
|
documents. There is no build step or runtime component; the prompts are consumed
|
||||||
|
by copying them into other projects or referencing them directly.
|
||||||
|
|
||||||
|
## TODO
|
||||||
|
|
||||||
|
- Add more prompt templates for common development tasks
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
MIT. See [LICENSE](LICENSE).
|
||||||
|
|
||||||
|
## Author
|
||||||
|
|
||||||
|
[@sjdev](https://sjdev.co)
|
||||||
@@ -0,0 +1,214 @@
|
|||||||
|
1. General Types
|
||||||
|
|
||||||
|
Don't ever use the types Number, String, Boolean, Symbol, or Object These types refer to non-primitive boxed objects that are almost never used appropriately in JavaScript code.
|
||||||
|
|
||||||
|
/* WRONG */
|
||||||
|
function reverse(s: String): String;
|
||||||
|
|
||||||
|
Do use the types number, string, boolean, and symbol.
|
||||||
|
|
||||||
|
/* OK */
|
||||||
|
function reverse(s: string): string;
|
||||||
|
|
||||||
|
Instead of Object, use the non-primitive object type.
|
||||||
|
|
||||||
|
2. Use const and let
|
||||||
|
|
||||||
|
JavaScript first searches to see if a variable exists locally, then searches progressively in higher levels of scope until global variables. var is function scope, but, let and const are block scope.
|
||||||
|
|
||||||
|
Using let and const where appropriate makes the intention of the declarations clearer.
|
||||||
|
|
||||||
|
It will also help in identifying issues when a value is reassigned to a constant accidentally by throwing a compile time error.
|
||||||
|
|
||||||
|
Use a linter that automates checking and fixing this so that changing let to const doesn't become a delay in code review.
|
||||||
|
|
||||||
|
3. Use === instead of ==
|
||||||
|
|
||||||
|
JavaScript utilizes two different kinds of equality operators: === | !== and == | !=.
|
||||||
|
|
||||||
|
It is considered best practice to always use the former set when comparing.
|
||||||
|
|
||||||
|
If two operands are of the same type and value, then === produces true and !== produces false.
|
||||||
|
|
||||||
|
However, when working with == and !=, we'll run into issues when working with different types. In these cases, they'll try to coerce the values, unsuccessfully.
|
||||||
|
|
||||||
|
4. Use the fastest way to loop arrays
|
||||||
|
|
||||||
|
There are many ways to loop through array. The first way is a for loop. Other ways include the for...of loop, the forEach method for arrays, map, filter, and others. There is also the while loop.
|
||||||
|
|
||||||
|
The for loop is the fastest way. Caching the length makes the loop perform better. Some browser engines have optimized the for loop without manually caching the length property. The forEach is slower than the for loop, so it's probably better to avoid it, especially for large arrays. However, unless we are desperate for performance at the code level (which is rare), make it readable. For example, we can use the for loop in server-side applications and the array methods in client-side applications, because, in general, we don't have expensive operations on the client-side.
|
||||||
|
|
||||||
|
5. Prefer array methods
|
||||||
|
|
||||||
|
It is recommended to use a functional approach without intermediate variables. The base JavaScript for loop can be more performant in some browsers but the benefit can be measured only by iterating over millions of items. It is the job of compiler and runtime to remove the penalty of using new array methods.
|
||||||
|
|
||||||
|
6. Do not trust any data - Validate
|
||||||
|
|
||||||
|
Make sure that all the data that goes into our system is clean and exactly what we need. This is most important on the back end when writing out parameters retrieved from the URL.
|
||||||
|
|
||||||
|
The same applies to forms that validate only on the client side. Another very insecure practice is to read information from the DOM and use it without validation.
|
||||||
|
|
||||||
|
7. Generics
|
||||||
|
|
||||||
|
Don't ever have a generic type which doesn't use its type parameter.
|
||||||
|
|
||||||
|
8. Any
|
||||||
|
|
||||||
|
Don't use any as a type unless you are in the process of migrating a JavaScript project to TypeScript. The compiler effectively treats any as "please turn off type checking for this thing". It is similar to putting an @ts-ignore comment around every usage of the variable. This can be very helpful when you are first migrating a JavaScript project to TypeScript as you can set the type for stuff you haven't migrated yet as any, but in a full TypeScript project you are disabling type checking for any parts of your program that use it.
|
||||||
|
In cases where you don't know what type you want to accept, or when you want to accept anything because you will be blindly passing it through without interacting with it, you can use unknown.
|
||||||
|
|
||||||
|
9. Strict configuration
|
||||||
|
|
||||||
|
The stricter configuration is mandatory. Otherwise, types will be too permissive, and it is what we are trying to avoid as much as possible with Typescript.
|
||||||
|
|
||||||
|
{
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"noImplicitReturns": true,
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
}
|
||||||
|
|
||||||
|
The most important one here is the strict flag which actually covers four other flags: noImplicitAny, noImplicitThis, alwaysStrict and strictNullChecks.
|
||||||
|
|
||||||
|
10. Callback Types - Return Types of Callbacks
|
||||||
|
|
||||||
|
Don't use the return type any for callbacks whose value will be ignored:
|
||||||
|
/* WRONG */
|
||||||
|
function fn(x: () => any) {
|
||||||
|
x();
|
||||||
|
}
|
||||||
|
|
||||||
|
Do use the return type void for callbacks whose value will be ignored:
|
||||||
|
/* OK */
|
||||||
|
function fn(x: () => void) {
|
||||||
|
x();
|
||||||
|
}
|
||||||
|
|
||||||
|
Why: Using void is safer because it prevents you from accidentally using the return value of x in an unchecked way:
|
||||||
|
function fn(x: () => void) {
|
||||||
|
var k = x(); // oops! meant to do something else
|
||||||
|
k.doSomething(); // error, but would be OK if the return type had been 'any'
|
||||||
|
}
|
||||||
|
|
||||||
|
11. Optional Parameters in Callbacks
|
||||||
|
|
||||||
|
Don't use optional parameters in callbacks unless you really mean it:
|
||||||
|
|
||||||
|
/* WRONG */
|
||||||
|
interface Fetcher {
|
||||||
|
getObject(done: (data: unknown, elapsedTime?: number) => void): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
This has a very specific meaning: the done callback might be invoked with 1 argument or might be invoked with 2 arguments. The author probably intended to say that the callback might not care about the elapsedTime parameter, but there's no need to make the parameter optional to accomplish this — it's always legal to provide a callback that accepts fewer arguments.
|
||||||
|
|
||||||
|
Do write callback parameters as non-optional:
|
||||||
|
/* OK */
|
||||||
|
interface Fetcher {
|
||||||
|
getObject(done: (data: unknown, elapsedTime: number) => void): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
12. Overloads and Callbacks
|
||||||
|
|
||||||
|
Don't write separate overloads that differ only on callback arity:
|
||||||
|
|
||||||
|
/* WRONG */
|
||||||
|
declare function beforeAll(action: () => void, timeout?: number): void;
|
||||||
|
declare function beforeAll(
|
||||||
|
action: (done: DoneFn) => void,
|
||||||
|
timeout?: number
|
||||||
|
): void;
|
||||||
|
|
||||||
|
Do write a single overload using the maximum arity:
|
||||||
|
|
||||||
|
/* OK */
|
||||||
|
declare function beforeAll(
|
||||||
|
action: (done: DoneFn) => void,
|
||||||
|
timeout?: number
|
||||||
|
): void;
|
||||||
|
|
||||||
|
Why: It's always legal for a callback to disregard a parameter, so there's no need for the shorter overload. Providing a shorter callback first allows incorrectly-typed functions to be passed in because they match the first overload.
|
||||||
|
|
||||||
|
13. Function Overloads
|
||||||
|
|
||||||
|
13.1 Ordering
|
||||||
|
|
||||||
|
Don't put more general overloads before more specific overloads:
|
||||||
|
/* WRONG */
|
||||||
|
declare function fn(x: unknown): unknown;
|
||||||
|
declare function fn(x: HTMLElement): number;
|
||||||
|
declare function fn(x: HTMLDivElement): string;
|
||||||
|
var myElem: HTMLDivElement;
|
||||||
|
var x = fn(myElem); // x: unknown, wat?
|
||||||
|
|
||||||
|
Do sort overloads by putting the more general signatures after more specific signatures:
|
||||||
|
|
||||||
|
/* OK */
|
||||||
|
declare function fn(x: HTMLDivElement): string;
|
||||||
|
declare function fn(x: HTMLElement): number;
|
||||||
|
declare function fn(x: unknown): unknown;
|
||||||
|
var myElem: HTMLDivElement;
|
||||||
|
var x = fn(myElem); // x: string, :)
|
||||||
|
|
||||||
|
Why: TypeScript chooses the first matching overload when resolving function calls. When an earlier overload is "more general" than a later one, the later one is effectively hidden and cannot be called.
|
||||||
|
|
||||||
|
13.2 Use Optional Parameters
|
||||||
|
|
||||||
|
Don't write several overloads that differ only in trailing parameters:
|
||||||
|
|
||||||
|
/* WRONG */
|
||||||
|
interface Example {
|
||||||
|
diff(one: string): number;
|
||||||
|
diff(one: string, two: string): number;
|
||||||
|
diff(one: string, two: string, three: boolean): number;
|
||||||
|
}
|
||||||
|
|
||||||
|
Do use optional parameters whenever possible:
|
||||||
|
|
||||||
|
/* OK */
|
||||||
|
interface Example {
|
||||||
|
diff(one: string, two?: string, three?: boolean): number;
|
||||||
|
}
|
||||||
|
|
||||||
|
Note that this collapsing should only occur when all overloads have the same return type.
|
||||||
|
|
||||||
|
Why: This is important for two reasons.
|
||||||
|
TypeScript resolves signature compatibility by seeing if any signature of the target can be invoked with the arguments of the source, and extraneous arguments are allowed. This code, for example, exposes a bug only when the signature is correctly written using optional parameters:
|
||||||
|
function fn(x: (a: string, b: number, c: number) => void) {}
|
||||||
|
var x: Example;
|
||||||
|
// When written with overloads, OK -- used first overload
|
||||||
|
// When written with optionals, correctly an error
|
||||||
|
fn(x.diff);
|
||||||
|
|
||||||
|
The second reason is when a consumer uses the "strict null checking" feature of TypeScript.
|
||||||
|
|
||||||
|
Because unspecified parameters appear as undefined in JavaScript, it's usually fine to pass an explicit undefined to a function with optional arguments. This code, for example, should be OK under strict nulls:
|
||||||
|
var x: Example;
|
||||||
|
// When written with overloads, incorrectly an error because of passing 'undefined' to 'string'
|
||||||
|
// When written with optionals, correctly OK
|
||||||
|
x.diff("something", true ? undefined : "hour");
|
||||||
|
|
||||||
|
13. Use Union Types
|
||||||
|
|
||||||
|
Don't write overloads that differ by type in only one argument position:
|
||||||
|
/* WRONG */
|
||||||
|
interface Moment {
|
||||||
|
utcOffset(): number;
|
||||||
|
utcOffset(b: number): Moment;
|
||||||
|
utcOffset(b: string): Moment;
|
||||||
|
}
|
||||||
|
|
||||||
|
Do use union types whenever possible:
|
||||||
|
/* OK */
|
||||||
|
interface Moment {
|
||||||
|
utcOffset(): number;
|
||||||
|
utcOffset(b: number | string): Moment;
|
||||||
|
}
|
||||||
|
Note that we didn't make b optional here because the return types of the signatures differ.
|
||||||
|
❔ Why: This is important for people who are "passing through" a value to your function:
|
||||||
|
function fn(x: string): Moment;
|
||||||
|
function fn(x: number): Moment;
|
||||||
|
function fn(x: number | string) {
|
||||||
|
// When written with separate overloads, incorrectly an error
|
||||||
|
// When written with union types, correctly OK
|
||||||
|
return moment().utcOffset(x);
|
||||||
|
}
|
||||||
5
LLM_DEV_PROMPTS/package.json
Normal file
5
LLM_DEV_PROMPTS/package.json
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"devDependencies": {
|
||||||
|
"prettier": "3.8.1"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -37,7 +37,7 @@ tidy:
|
|||||||
|
|
||||||
# Database setup
|
# Database setup
|
||||||
db-setup:
|
db-setup:
|
||||||
psql -d koin_ping_dev -f infra/schema.sql
|
psql -d koin_ping -f infra/schema.sql
|
||||||
|
|
||||||
vet:
|
vet:
|
||||||
go vet ./...
|
go vet ./...
|
||||||
|
|||||||
@@ -1,20 +1,33 @@
|
|||||||
Start DB:
|
Backend startup quickstart:
|
||||||
|
|
||||||
brew services start postgresql@15
|
-----------------------------> BEST
|
||||||
|
## 1. brew services start postgresql@15
|
||||||
|
|
||||||
From the backend directory, you have a few options:
|
OR
|
||||||
|
|
||||||
Option 1: Single command (both API + poller)
|
/opt/homebrew/opt/postgresql@15/bin/pg_ctl -D /opt/homebrew/var/postgresql@15 start
|
||||||
cd /Users/kjannette/workspace/koin_ping_0.2.0/backendmake dev-all
|
|
||||||
|
|
||||||
Option 2: Two separate terminals
|
## 2. ALLIN ONE:
|
||||||
Terminal 1 (API server):
|
Make dev-all — Runs both the API and poller concurrently.
|
||||||
cd /Users/kjannette/workspace/koin_ping_0.2.0/backend go run ./cmd/api
|
-----------------------------> BEST
|
||||||
Terminal 2 (Poller):
|
|
||||||
cd /Users/kjannette/workspace/koin_ping_0.2.0/backend go run ./cmd/poller
|
- OR -
|
||||||
|
|
||||||
|
|
||||||
|
## 3. Option 1: Single command (both API + poller)
|
||||||
|
cd /Users/kjannette/workspace/koin_ping_0.2.0/backendmake dev-all
|
||||||
|
|
||||||
|
## 4. Option 2: Two separate terminals
|
||||||
|
Terminal 1 (API server):
|
||||||
|
cd /Users/kjannette/workspace/koin_ping_0.2.0/backend go run ./cmd/api
|
||||||
|
|
||||||
|
make run — Builds and runs the API server.
|
||||||
|
make dev — Runs the API server with auto-reload via air (falls back to go run if air isn't installed).
|
||||||
|
|
||||||
|
## 5. Terminal 2 (Poller):
|
||||||
|
cd /Users/kjannette/workspace/koin_ping_0.2.0/backend
|
||||||
|
go run ./cmd/poller
|
||||||
|
|
||||||
|
make poller — Builds and runs the poller.
|
||||||
|
make poller-dev — Runs the poller with auto-reload.
|
||||||
|
|
||||||
make run — Builds and runs the API server.
|
|
||||||
make dev — Runs the API server with auto-reload via air (falls back to go run if air isn't installed).
|
|
||||||
make poller — Builds and runs the poller.
|
|
||||||
make poller-dev — Runs the poller with auto-reload.
|
|
||||||
make dev-all — Runs both the API and poller concurrently.
|
|
||||||
BIN
backend/api
BIN
backend/api
Binary file not shown.
@@ -55,14 +55,15 @@ func main() {
|
|||||||
cfg.ResendAPIKey, cfg.EmailFrom, alertEventModel, notifConfigModel,
|
cfg.ResendAPIKey, cfg.EmailFrom, alertEventModel, notifConfigModel,
|
||||||
)
|
)
|
||||||
|
|
||||||
addressHandler := handlers.NewAddressHandler(addressModel)
|
addressHandler := handlers.NewAddressHandler(addressModel, userModel)
|
||||||
alertRuleHandler := handlers.NewAlertRuleHandler(alertRuleModel, addressModel)
|
alertRuleHandler := handlers.NewAlertRuleHandler(alertRuleModel, addressModel, userModel)
|
||||||
alertEventHandler := handlers.NewAlertEventHandler(alertEventModel)
|
alertEventHandler := handlers.NewAlertEventHandler(alertEventModel)
|
||||||
notifConfigHandler := handlers.NewNotificationConfigHandler(notifConfigModel, cfg)
|
notifConfigHandler := handlers.NewNotificationConfigHandler(notifConfigModel, userModel, cfg)
|
||||||
emailDigestHandler := handlers.NewEmailDigestHandler(emailDigestSvc, notifConfigModel)
|
emailDigestHandler := handlers.NewEmailDigestHandler(emailDigestSvc, notifConfigModel)
|
||||||
statusHandler := handlers.NewStatusHandler(checkpointModel)
|
statusHandler := handlers.NewStatusHandler(checkpointModel)
|
||||||
stripeHandler := handlers.NewStripeHandler(userModel, cfg)
|
stripeHandler := handlers.NewStripeHandler(userModel, alertRuleModel, cfg)
|
||||||
accountHandler := handlers.NewAccountHandler(userModel, cfg)
|
accountHandler := handlers.NewAccountHandler(userModel, addressModel, cfg)
|
||||||
|
supportHandler := handlers.NewSupportHandler(cfg)
|
||||||
|
|
||||||
authenticate := middleware.Authenticate(userModel)
|
authenticate := middleware.Authenticate(userModel)
|
||||||
requireSub := middleware.RequireSubscription(userModel)
|
requireSub := middleware.RequireSubscription(userModel)
|
||||||
@@ -82,6 +83,9 @@ func main() {
|
|||||||
// Stripe webhook (public — called by Stripe, not authenticated)
|
// Stripe webhook (public — called by Stripe, not authenticated)
|
||||||
mux.HandleFunc("POST "+b+"/stripe/webhook", stripeHandler.HandleWebhook)
|
mux.HandleFunc("POST "+b+"/stripe/webhook", stripeHandler.HandleWebhook)
|
||||||
|
|
||||||
|
// Onboarding checkout (public — account doesn't exist yet)
|
||||||
|
mux.HandleFunc("POST "+b+"/stripe/create-onboarding-checkout", stripeHandler.CreateOnboardingCheckout)
|
||||||
|
|
||||||
// Stripe routes (auth required, NO subscription required)
|
// Stripe routes (auth required, NO subscription required)
|
||||||
mux.Handle("POST "+b+"/stripe/create-checkout-session",
|
mux.Handle("POST "+b+"/stripe/create-checkout-session",
|
||||||
authenticate(http.HandlerFunc(stripeHandler.CreateCheckoutSession)))
|
authenticate(http.HandlerFunc(stripeHandler.CreateCheckoutSession)))
|
||||||
@@ -91,11 +95,17 @@ func main() {
|
|||||||
authenticate(http.HandlerFunc(stripeHandler.VerifyCheckoutSession)))
|
authenticate(http.HandlerFunc(stripeHandler.VerifyCheckoutSession)))
|
||||||
mux.Handle("POST "+b+"/stripe/create-portal-session",
|
mux.Handle("POST "+b+"/stripe/create-portal-session",
|
||||||
authenticate(http.HandlerFunc(stripeHandler.CreatePortalSession)))
|
authenticate(http.HandlerFunc(stripeHandler.CreatePortalSession)))
|
||||||
|
mux.Handle("POST "+b+"/stripe/activate-free",
|
||||||
|
authenticate(http.HandlerFunc(stripeHandler.ActivateFreeTier)))
|
||||||
|
|
||||||
// Account route (auth required, NO subscription required)
|
// Account route (auth required, NO subscription required)
|
||||||
mux.Handle("GET "+b+"/user/account",
|
mux.Handle("GET "+b+"/user/account",
|
||||||
authenticate(http.HandlerFunc(accountHandler.GetAccount)))
|
authenticate(http.HandlerFunc(accountHandler.GetAccount)))
|
||||||
|
|
||||||
|
// Support (auth required; avoids anonymous spam)
|
||||||
|
mux.Handle("POST "+b+"/support",
|
||||||
|
authenticate(http.HandlerFunc(supportHandler.Submit)))
|
||||||
|
|
||||||
// Authenticated + subscribed routes — addresses
|
// Authenticated + subscribed routes — addresses
|
||||||
mux.Handle("POST "+b+"/addresses",
|
mux.Handle("POST "+b+"/addresses",
|
||||||
authAndSub(http.HandlerFunc(addressHandler.Create)))
|
authAndSub(http.HandlerFunc(addressHandler.Create)))
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ func main() {
|
|||||||
|
|
||||||
defer database.Close()
|
defer database.Close()
|
||||||
|
|
||||||
|
userModel := models.NewUserModel(pool)
|
||||||
addressModel := models.NewAddressModel(pool)
|
addressModel := models.NewAddressModel(pool)
|
||||||
alertRuleModel := models.NewAlertRuleModel(pool)
|
alertRuleModel := models.NewAlertRuleModel(pool)
|
||||||
alertEventModel := models.NewAlertEventModel(pool)
|
alertEventModel := models.NewAlertEventModel(pool)
|
||||||
@@ -59,8 +60,7 @@ func main() {
|
|||||||
|
|
||||||
observer := services.NewObserverService(eth, addressModel, checkpointModel)
|
observer := services.NewObserverService(eth, addressModel, checkpointModel)
|
||||||
evaluator := services.NewEvaluatorService(
|
evaluator := services.NewEvaluatorService(
|
||||||
eth, alertRuleModel, alertEventModel, addressModel, notifConfigModel,
|
eth, alertRuleModel, alertEventModel, addressModel, userModel, notifConfigModel,
|
||||||
cfg.ResendAPIKey, cfg.EmailFrom,
|
|
||||||
)
|
)
|
||||||
digestSvc := services.NewEmailDigestService(cfg.ResendAPIKey, cfg.EmailFrom, alertEventModel, notifConfigModel)
|
digestSvc := services.NewEmailDigestService(cfg.ResendAPIKey, cfg.EmailFrom, alertEventModel, notifConfigModel)
|
||||||
|
|
||||||
|
|||||||
68
backend/cmd/subscription-sweep/main.go
Normal file
68
backend/cmd/subscription-sweep/main.go
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
// Package main runs a daily (cron-invoked) job: for paid tiers without an
|
||||||
|
// active or trialling Stripe subscription, disable Firebase login and turn
|
||||||
|
// off all alert rules until billing is restored via Stripe webhook / checkout.
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"log"
|
||||||
|
|
||||||
|
"github.com/joho/godotenv"
|
||||||
|
|
||||||
|
"github.com/kjannette/koin-ping/backend/internal/config"
|
||||||
|
"github.com/kjannette/koin-ping/backend/internal/database"
|
||||||
|
"github.com/kjannette/koin-ping/backend/internal/firebase"
|
||||||
|
"github.com/kjannette/koin-ping/backend/internal/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
_ = godotenv.Load()
|
||||||
|
|
||||||
|
cfg, err := config.Load()
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("Failed to load config: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := firebase.Init(cfg.FirebaseProjectID); err != nil {
|
||||||
|
log.Fatalf("Failed to initialize Firebase: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
pool, connErr := database.Connect(cfg.DSN())
|
||||||
|
if connErr != nil {
|
||||||
|
log.Fatalf("Failed to connect to database: %v", connErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
defer database.Close()
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
userModel := models.NewUserModel(pool)
|
||||||
|
alertModel := models.NewAlertRuleModel(pool)
|
||||||
|
|
||||||
|
users, listErr := userModel.ListPaidUsersWithoutActiveSubscription(ctx)
|
||||||
|
if listErr != nil {
|
||||||
|
log.Fatalf("Failed to list lapsed subscriptions: %v", listErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(users) == 0 {
|
||||||
|
log.Println("Subscription sweep: no lapsed paid users")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, u := range users {
|
||||||
|
if disableErr := firebase.SetUserDisabled(ctx, u.FirebaseUID, true); disableErr != nil {
|
||||||
|
log.Printf("Subscription sweep: Firebase disable failed user %s: %v", u.ID, disableErr)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
n, rulesErr := alertModel.DisableAllForUser(ctx, u.ID)
|
||||||
|
if rulesErr != nil {
|
||||||
|
log.Printf("Subscription sweep: disabling alerts failed for user %s: %v", u.ID, rulesErr)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf(
|
||||||
|
"Subscription sweep: suspended user %s (%s tier, status=%s), disabled %d alert rules",
|
||||||
|
u.ID, u.SubscriptionTier, u.SubscriptionStatus, n,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,7 +4,10 @@ go 1.25.0
|
|||||||
|
|
||||||
require (
|
require (
|
||||||
firebase.google.com/go/v4 v4.19.0
|
firebase.google.com/go/v4 v4.19.0
|
||||||
github.com/jackc/pgx/v5 v5.8.0
|
github.com/jackc/pgx/v5 v5.9.2
|
||||||
|
github.com/joho/godotenv v1.5.1
|
||||||
|
github.com/stripe/stripe-go/v82 v82.5.1
|
||||||
|
golang.org/x/sync v0.19.0
|
||||||
google.golang.org/api v0.269.0
|
google.golang.org/api v0.269.0
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -40,25 +43,21 @@ require (
|
|||||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||||
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||||
github.com/joho/godotenv v1.5.1 // indirect
|
|
||||||
github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect
|
github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect
|
||||||
github.com/resend/resend-go/v3 v3.1.1 // indirect
|
|
||||||
github.com/spiffe/go-spiffe/v2 v2.6.0 // indirect
|
github.com/spiffe/go-spiffe/v2 v2.6.0 // indirect
|
||||||
github.com/stripe/stripe-go/v82 v82.5.1 // indirect
|
|
||||||
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
|
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
|
||||||
go.opentelemetry.io/contrib/detectors/gcp v1.39.0 // indirect
|
go.opentelemetry.io/contrib/detectors/gcp v1.39.0 // indirect
|
||||||
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.61.0 // indirect
|
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.61.0 // indirect
|
||||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 // indirect
|
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 // indirect
|
||||||
go.opentelemetry.io/otel v1.39.0 // indirect
|
go.opentelemetry.io/otel v1.43.0 // indirect
|
||||||
go.opentelemetry.io/otel/metric v1.39.0 // indirect
|
go.opentelemetry.io/otel/metric v1.43.0 // indirect
|
||||||
go.opentelemetry.io/otel/sdk v1.39.0 // indirect
|
go.opentelemetry.io/otel/sdk v1.43.0 // indirect
|
||||||
go.opentelemetry.io/otel/sdk/metric v1.39.0 // indirect
|
go.opentelemetry.io/otel/sdk/metric v1.43.0 // indirect
|
||||||
go.opentelemetry.io/otel/trace v1.39.0 // indirect
|
go.opentelemetry.io/otel/trace v1.43.0 // indirect
|
||||||
golang.org/x/crypto v0.48.0 // indirect
|
golang.org/x/crypto v0.48.0 // indirect
|
||||||
golang.org/x/net v0.50.0 // indirect
|
golang.org/x/net v0.50.0 // indirect
|
||||||
golang.org/x/oauth2 v0.35.0 // indirect
|
golang.org/x/oauth2 v0.35.0 // indirect
|
||||||
golang.org/x/sync v0.19.0 // indirect
|
golang.org/x/sys v0.42.0 // indirect
|
||||||
golang.org/x/sys v0.41.0 // indirect
|
|
||||||
golang.org/x/text v0.34.0 // indirect
|
golang.org/x/text v0.34.0 // indirect
|
||||||
golang.org/x/time v0.14.0 // indirect
|
golang.org/x/time v0.14.0 // indirect
|
||||||
google.golang.org/appengine/v2 v2.0.6 // indirect
|
google.golang.org/appengine/v2 v2.0.6 // indirect
|
||||||
@@ -68,3 +67,11 @@ require (
|
|||||||
google.golang.org/grpc v1.79.1 // indirect
|
google.golang.org/grpc v1.79.1 // indirect
|
||||||
google.golang.org/protobuf v1.36.11 // indirect
|
google.golang.org/protobuf v1.36.11 // indirect
|
||||||
)
|
)
|
||||||
|
|
||||||
|
replace google.golang.org/grpc v1.79.1 => google.golang.org/grpc v1.79.3
|
||||||
|
|
||||||
|
replace go.opentelemetry.io/otel v1.39.0 => go.opentelemetry.io/otel v1.41.0
|
||||||
|
|
||||||
|
replace go.opentelemetry.io/otel/sdk v1.39.0 => go.opentelemetry.io/otel/sdk v1.43.0
|
||||||
|
|
||||||
|
replace github.com/go-jose/go-jose/v4 v4.1.3 => github.com/go-jose/go-jose/v4 v4.1.4
|
||||||
|
|||||||
@@ -51,8 +51,8 @@ github.com/envoyproxy/protoc-gen-validate v1.3.0 h1:TvGH1wof4H33rezVKWSpqKz5NXWg
|
|||||||
github.com/envoyproxy/protoc-gen-validate v1.3.0/go.mod h1:HvYl7zwPa5mffgyeTUHA9zHIH36nmrm7oCbo4YKoSWA=
|
github.com/envoyproxy/protoc-gen-validate v1.3.0/go.mod h1:HvYl7zwPa5mffgyeTUHA9zHIH36nmrm7oCbo4YKoSWA=
|
||||||
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
|
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
|
||||||
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
|
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
|
||||||
github.com/go-jose/go-jose/v4 v4.1.3 h1:CVLmWDhDVRa6Mi/IgCgaopNosCaHz7zrMeF9MlZRkrs=
|
github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA=
|
||||||
github.com/go-jose/go-jose/v4 v4.1.3/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
|
github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
|
||||||
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
|
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
|
||||||
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
||||||
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||||
@@ -81,8 +81,8 @@ github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsI
|
|||||||
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
||||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
||||||
github.com/jackc/pgx/v5 v5.8.0 h1:TYPDoleBBme0xGSAX3/+NujXXtpZn9HBONkQC7IEZSo=
|
github.com/jackc/pgx/v5 v5.9.2 h1:3ZhOzMWnR4yJ+RW1XImIPsD1aNSz4T4fyP7zlQb56hw=
|
||||||
github.com/jackc/pgx/v5 v5.8.0/go.mod h1:QVeDInX2m9VyzvNeiCJVjCkNFqzsNb43204HshNSZKw=
|
github.com/jackc/pgx/v5 v5.9.2/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
|
||||||
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
||||||
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||||
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
|
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
|
||||||
@@ -92,8 +92,6 @@ github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10/go.mod h1
|
|||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
|
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
|
||||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
github.com/resend/resend-go/v3 v3.1.1 h1:Uwpf/tZU+O/r/3nMWE6zUAMIG9dX/vTBS3wlQzYJKSw=
|
|
||||||
github.com/resend/resend-go/v3 v3.1.1/go.mod h1:iI7VA0NoGjWvsNii5iNC5Dy0llsI3HncXPejhniYzwE=
|
|
||||||
github.com/spiffe/go-spiffe/v2 v2.6.0 h1:l+DolpxNWYgruGQVV0xsfeya3CsC7m8iBzDnMpsbLuo=
|
github.com/spiffe/go-spiffe/v2 v2.6.0 h1:l+DolpxNWYgruGQVV0xsfeya3CsC7m8iBzDnMpsbLuo=
|
||||||
github.com/spiffe/go-spiffe/v2 v2.6.0/go.mod h1:gm2SeUoMZEtpnzPNs2Csc0D/gX33k1xIx7lEzqblHEs=
|
github.com/spiffe/go-spiffe/v2 v2.6.0/go.mod h1:gm2SeUoMZEtpnzPNs2Csc0D/gX33k1xIx7lEzqblHEs=
|
||||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
@@ -112,18 +110,18 @@ go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.6
|
|||||||
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.61.0/go.mod h1:snMWehoOh2wsEwnvvwtDyFCxVeDAODenXHtn5vzrKjo=
|
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.61.0/go.mod h1:snMWehoOh2wsEwnvvwtDyFCxVeDAODenXHtn5vzrKjo=
|
||||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 h1:F7Jx+6hwnZ41NSFTO5q4LYDtJRXBf2PD0rNBkeB/lus=
|
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 h1:F7Jx+6hwnZ41NSFTO5q4LYDtJRXBf2PD0rNBkeB/lus=
|
||||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0/go.mod h1:UHB22Z8QsdRDrnAtX4PntOl36ajSxcdUMt1sF7Y6E7Q=
|
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0/go.mod h1:UHB22Z8QsdRDrnAtX4PntOl36ajSxcdUMt1sF7Y6E7Q=
|
||||||
go.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48=
|
go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I=
|
||||||
go.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8=
|
go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0=
|
||||||
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.36.0 h1:rixTyDGXFxRy1xzhKrotaHy3/KXdPhlWARrCgK+eqUY=
|
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.36.0 h1:rixTyDGXFxRy1xzhKrotaHy3/KXdPhlWARrCgK+eqUY=
|
||||||
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.36.0/go.mod h1:dowW6UsM9MKbJq5JTz2AMVp3/5iW5I/TStsk8S+CfHw=
|
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.36.0/go.mod h1:dowW6UsM9MKbJq5JTz2AMVp3/5iW5I/TStsk8S+CfHw=
|
||||||
go.opentelemetry.io/otel/metric v1.39.0 h1:d1UzonvEZriVfpNKEVmHXbdf909uGTOQjA0HF0Ls5Q0=
|
go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM=
|
||||||
go.opentelemetry.io/otel/metric v1.39.0/go.mod h1:jrZSWL33sD7bBxg1xjrqyDjnuzTUB0x1nBERXd7Ftcs=
|
go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY=
|
||||||
go.opentelemetry.io/otel/sdk v1.39.0 h1:nMLYcjVsvdui1B/4FRkwjzoRVsMK8uL/cj0OyhKzt18=
|
go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg=
|
||||||
go.opentelemetry.io/otel/sdk v1.39.0/go.mod h1:vDojkC4/jsTJsE+kh+LXYQlbL8CgrEcwmt1ENZszdJE=
|
go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg=
|
||||||
go.opentelemetry.io/otel/sdk/metric v1.39.0 h1:cXMVVFVgsIf2YL6QkRF4Urbr/aMInf+2WKg+sEJTtB8=
|
go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw=
|
||||||
go.opentelemetry.io/otel/sdk/metric v1.39.0/go.mod h1:xq9HEVH7qeX69/JnwEfp6fVq5wosJsY1mt4lLfYdVew=
|
go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A=
|
||||||
go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI=
|
go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A=
|
||||||
go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA=
|
go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0=
|
||||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||||
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
|
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
|
||||||
@@ -145,8 +143,8 @@ golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7w
|
|||||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
|
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
|
||||||
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||||
@@ -174,8 +172,8 @@ google.golang.org/genproto/googleapis/api v0.0.0-20260128011058-8636f8732409 h1:
|
|||||||
google.golang.org/genproto/googleapis/api v0.0.0-20260128011058-8636f8732409/go.mod h1:fl8J1IvUjCilwZzQowmw2b7HQB2eAuYBabMXzWurF+I=
|
google.golang.org/genproto/googleapis/api v0.0.0-20260128011058-8636f8732409/go.mod h1:fl8J1IvUjCilwZzQowmw2b7HQB2eAuYBabMXzWurF+I=
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260217215200-42d3e9bedb6d h1:t/LOSXPJ9R0B6fnZNyALBRfZBH0Uy0gT+uR+SJ6syqQ=
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20260217215200-42d3e9bedb6d h1:t/LOSXPJ9R0B6fnZNyALBRfZBH0Uy0gT+uR+SJ6syqQ=
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260217215200-42d3e9bedb6d/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20260217215200-42d3e9bedb6d/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
|
||||||
google.golang.org/grpc v1.79.1 h1:zGhSi45ODB9/p3VAawt9a+O/MULLl9dpizzNNpq7flY=
|
google.golang.org/grpc v1.79.3 h1:sybAEdRIEtvcD68Gx7dmnwjZKlyfuc61Dyo9pGXXkKE=
|
||||||
google.golang.org/grpc v1.79.1/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ=
|
google.golang.org/grpc v1.79.3/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ=
|
||||||
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
|
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
|
||||||
google.golang.org/protobuf v1.30.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
|
google.golang.org/protobuf v1.30.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
|
||||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
ALTER TABLE alert_rules ADD COLUMN minimum DECIMAL(20, 6);
|
||||||
|
ALTER TABLE alert_rules ADD COLUMN maximum DECIMAL(20, 6);
|
||||||
|
|
||||||
|
ALTER TABLE alert_rules ADD CONSTRAINT non_negative_minimum CHECK (minimum IS NULL OR minimum >= 0);
|
||||||
|
ALTER TABLE alert_rules ADD CONSTRAINT non_negative_maximum CHECK (maximum IS NULL OR maximum >= 0);
|
||||||
|
ALTER TABLE alert_rules ADD CONSTRAINT min_lte_max CHECK (minimum IS NULL OR maximum IS NULL OR minimum <= maximum);
|
||||||
8
backend/infra/migrations/009_add_subscription_tier.sql
Normal file
8
backend/infra/migrations/009_add_subscription_tier.sql
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
ALTER TABLE users ADD COLUMN IF NOT EXISTS subscription_tier VARCHAR(20) DEFAULT 'free';
|
||||||
|
|
||||||
|
-- Existing active/trialing subscribers were on the single paid plan,
|
||||||
|
-- which is now the "premium" tier. Backfill them so they aren't downgraded.
|
||||||
|
UPDATE users
|
||||||
|
SET subscription_tier = 'premium'
|
||||||
|
WHERE subscription_status IN ('active', 'trialing')
|
||||||
|
AND stripe_subscription_id IS NOT NULL;
|
||||||
20
backend/infra/migrations/010_add_unique_email_index.sql
Normal file
20
backend/infra/migrations/010_add_unique_email_index.sql
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
-- Migration 010: enforce unique emails in users
|
||||||
|
-- Matches runtime expectation for idx_users_email_unique.
|
||||||
|
|
||||||
|
BEGIN;
|
||||||
|
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM users
|
||||||
|
GROUP BY email
|
||||||
|
HAVING COUNT(*) > 1
|
||||||
|
) THEN
|
||||||
|
RAISE EXCEPTION 'Cannot create unique index idx_users_email_unique: duplicate emails exist in users';
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_users_email_unique ON users(email);
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
@@ -14,6 +14,7 @@ CREATE TABLE users (
|
|||||||
stripe_customer_id VARCHAR(255),
|
stripe_customer_id VARCHAR(255),
|
||||||
stripe_subscription_id VARCHAR(255),
|
stripe_subscription_id VARCHAR(255),
|
||||||
subscription_status VARCHAR(50) DEFAULT 'none',
|
subscription_status VARCHAR(50) DEFAULT 'none',
|
||||||
|
subscription_tier VARCHAR(20) DEFAULT 'free',
|
||||||
subscription_created_at TIMESTAMP,
|
subscription_created_at TIMESTAMP,
|
||||||
created_at TIMESTAMP DEFAULT NOW(),
|
created_at TIMESTAMP DEFAULT NOW(),
|
||||||
updated_at TIMESTAMP DEFAULT NOW()
|
updated_at TIMESTAMP DEFAULT NOW()
|
||||||
@@ -35,6 +36,8 @@ CREATE TABLE alert_rules (
|
|||||||
address_id INTEGER NOT NULL REFERENCES addresses(id) ON DELETE CASCADE,
|
address_id INTEGER NOT NULL REFERENCES addresses(id) ON DELETE CASCADE,
|
||||||
type VARCHAR(50) NOT NULL, -- 'incoming_tx', 'outgoing_tx', 'large_transfer', 'balance_below'
|
type VARCHAR(50) NOT NULL, -- 'incoming_tx', 'outgoing_tx', 'large_transfer', 'balance_below'
|
||||||
threshold DECIMAL(20, 6), -- ETH amount threshold (nullable for tx types that don't need it)
|
threshold DECIMAL(20, 6), -- ETH amount threshold (nullable for tx types that don't need it)
|
||||||
|
minimum DECIMAL(20, 6), -- Optional min amount filter for incoming/outgoing alerts
|
||||||
|
maximum DECIMAL(20, 6), -- Optional max amount filter for incoming/outgoing alerts
|
||||||
enabled BOOLEAN DEFAULT TRUE,
|
enabled BOOLEAN DEFAULT TRUE,
|
||||||
created_at TIMESTAMP DEFAULT NOW(),
|
created_at TIMESTAMP DEFAULT NOW(),
|
||||||
|
|
||||||
@@ -44,7 +47,11 @@ CREATE TABLE alert_rules (
|
|||||||
|
|
||||||
CONSTRAINT positive_threshold CHECK (
|
CONSTRAINT positive_threshold CHECK (
|
||||||
threshold IS NULL OR threshold > 0
|
threshold IS NULL OR threshold > 0
|
||||||
)
|
),
|
||||||
|
|
||||||
|
CONSTRAINT non_negative_minimum CHECK (minimum IS NULL OR minimum >= 0),
|
||||||
|
CONSTRAINT non_negative_maximum CHECK (maximum IS NULL OR maximum >= 0),
|
||||||
|
CONSTRAINT min_lte_max CHECK (minimum IS NULL OR maximum IS NULL OR minimum <= maximum)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
//loads environment-based configuration.
|
// loads environment-based configuration.
|
||||||
package config
|
package config
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -9,59 +9,67 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
defaultPort = 3001
|
defaultPort = 3001
|
||||||
defaultDBPort = 5432
|
defaultDBPort = 5432
|
||||||
defaultPollIntervalMS = 60000
|
defaultPollIntervalMS = 60000
|
||||||
minPollIntervalMS = 1000
|
minPollIntervalMS = 1000
|
||||||
defaultDigestIntervalHours = 24
|
defaultDigestIntervalHours = 24
|
||||||
)
|
)
|
||||||
|
|
||||||
type Config struct {
|
type Config struct {
|
||||||
Port int
|
Port int
|
||||||
APIBasePath string
|
APIBasePath string
|
||||||
DatabaseURL string
|
DatabaseURL string
|
||||||
DBHost string
|
DBHost string
|
||||||
DBPort int
|
DBPort int
|
||||||
DBUser string
|
DBUser string
|
||||||
DBPassword string
|
DBPassword string
|
||||||
DBName string
|
DBName string
|
||||||
FirebaseProjectID string
|
FirebaseProjectID string
|
||||||
EthRPCURL string
|
EthRPCURL string
|
||||||
PollIntervalMS int
|
PollIntervalMS int
|
||||||
NodeEnv string
|
NodeEnv string
|
||||||
ResendAPIKey string
|
ResendAPIKey string
|
||||||
EmailFrom string
|
EmailFrom string
|
||||||
DigestIntervalHours int
|
SupportInboxEmail string
|
||||||
StripeSecretKey string
|
DigestIntervalHours int
|
||||||
StripeWebhookSecret string
|
StripeSecretKey string
|
||||||
StripePriceID string
|
StripeWebhookSecret string
|
||||||
StripePublishableKey string
|
StripePriceIDPremium string
|
||||||
FrontendURL string
|
StripePriceIDPro string
|
||||||
|
StripePriceIDPremiumAnnual string
|
||||||
|
StripePriceIDProAnnual string
|
||||||
|
StripePublishableKey string
|
||||||
|
FrontendURL string
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load reads configuration from environment variables and returns a Config.
|
// Load reads configuration from environment variables and returns a Config.
|
||||||
func Load() (*Config, error) {
|
func Load() (*Config, error) {
|
||||||
cfg := &Config{
|
cfg := &Config{
|
||||||
Port: getEnvInt("PORT", defaultPort),
|
Port: getEnvInt("PORT", defaultPort),
|
||||||
APIBasePath: getEnv("API_BASE_PATH", "/v1"),
|
APIBasePath: getEnv("API_BASE_PATH", "/v1"),
|
||||||
DatabaseURL: os.Getenv("DATABASE_URL"),
|
DatabaseURL: os.Getenv("DATABASE_URL"),
|
||||||
DBHost: getEnv("DB_HOST", "localhost"),
|
DBHost: getEnv("DB_HOST", "localhost"),
|
||||||
DBPort: getEnvInt("DB_PORT", defaultDBPort),
|
DBPort: getEnvInt("DB_PORT", defaultDBPort),
|
||||||
DBUser: os.Getenv("DB_USER"),
|
DBUser: os.Getenv("DB_USER"),
|
||||||
DBPassword: os.Getenv("DB_PASSWORD"),
|
DBPassword: os.Getenv("DB_PASSWORD"),
|
||||||
DBName: os.Getenv("DB_NAME"),
|
DBName: os.Getenv("DB_NAME"),
|
||||||
FirebaseProjectID: os.Getenv("FIREBASE_PROJECT_ID"),
|
FirebaseProjectID: os.Getenv("FIREBASE_PROJECT_ID"),
|
||||||
EthRPCURL: os.Getenv("ETH_RPC_URL"),
|
EthRPCURL: os.Getenv("ETH_RPC_URL"),
|
||||||
PollIntervalMS: getEnvInt("POLL_INTERVAL_MS", defaultPollIntervalMS),
|
PollIntervalMS: getEnvInt("POLL_INTERVAL_MS", defaultPollIntervalMS),
|
||||||
NodeEnv: getEnv("NODE_ENV", "development"),
|
NodeEnv: getEnv("NODE_ENV", "development"),
|
||||||
ResendAPIKey: os.Getenv("RESEND_API_KEY"),
|
ResendAPIKey: os.Getenv("RESEND_API_KEY"),
|
||||||
EmailFrom: getEnv("EMAIL_FROM", "Koin Ping <alerts@koinping.com>"),
|
EmailFrom: getEnv("EMAIL_FROM", "Koin Ping <alerts@koinping.com>"),
|
||||||
DigestIntervalHours: getEnvInt("DIGEST_INTERVAL_HOURS", defaultDigestIntervalHours),
|
SupportInboxEmail: getEnv("SUPPORT_INBOX_EMAIL", "sj@sjdev.co"),
|
||||||
StripeSecretKey: os.Getenv("STRIPE_SECRET_KEY"),
|
DigestIntervalHours: getEnvInt("DIGEST_INTERVAL_HOURS", defaultDigestIntervalHours),
|
||||||
StripeWebhookSecret: os.Getenv("STRIPE_WEBHOOK_SECRET"),
|
StripeSecretKey: os.Getenv("STRIPE_SECRET_KEY"),
|
||||||
StripePriceID: os.Getenv("STRIPE_PRICE_ID"),
|
StripeWebhookSecret: os.Getenv("STRIPE_WEBHOOK_SECRET"),
|
||||||
StripePublishableKey: os.Getenv("STRIPE_PUBLISHABLE_KEY"),
|
StripePriceIDPremium: os.Getenv("STRIPE_PRICE_ID_PREMIUM"),
|
||||||
FrontendURL: getEnv("FRONTEND_URL", "http://localhost:3000"),
|
StripePriceIDPro: os.Getenv("STRIPE_PRICE_ID_PRO"),
|
||||||
|
StripePriceIDPremiumAnnual: os.Getenv("STRIPE_PRICE_ID_PREMIUM_ANNUAL"),
|
||||||
|
StripePriceIDProAnnual: os.Getenv("STRIPE_PRICE_ID_PRO_ANNUAL"),
|
||||||
|
StripePublishableKey: os.Getenv("STRIPE_PUBLISHABLE_KEY"),
|
||||||
|
FrontendURL: getEnv("FRONTEND_URL", "http://localhost:3000"),
|
||||||
}
|
}
|
||||||
|
|
||||||
if cfg.PollIntervalMS < minPollIntervalMS {
|
if cfg.PollIntervalMS < minPollIntervalMS {
|
||||||
@@ -92,6 +100,22 @@ func (c *Config) DSN() string {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TierForPriceID maps a Stripe price ID back to the corresponding
|
||||||
|
// subscription tier. Returns empty string if the price is unrecognised.
|
||||||
|
func (c *Config) TierForPriceID(priceID string) string {
|
||||||
|
if priceID == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
switch priceID {
|
||||||
|
case c.StripePriceIDPremium, c.StripePriceIDPremiumAnnual:
|
||||||
|
return "premium"
|
||||||
|
case c.StripePriceIDPro, c.StripePriceIDProAnnual:
|
||||||
|
return "pro"
|
||||||
|
default:
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func getEnv(key, fallback string) string {
|
func getEnv(key, fallback string) string {
|
||||||
if v := os.Getenv(key); v != "" {
|
if v := os.Getenv(key); v != "" {
|
||||||
return v
|
return v
|
||||||
|
|||||||
32
backend/internal/config/config_test.go
Normal file
32
backend/internal/config/config_test.go
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestTierForPriceID(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
cfg := &Config{
|
||||||
|
StripePriceIDPremium: "price_premium_123",
|
||||||
|
StripePriceIDPro: "price_pro_456",
|
||||||
|
StripePriceIDPremiumAnnual: "price_premium_yr",
|
||||||
|
StripePriceIDProAnnual: "price_pro_yr",
|
||||||
|
}
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
priceID string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{"price_premium_123", "premium"},
|
||||||
|
{"price_pro_456", "pro"},
|
||||||
|
{"price_premium_yr", "premium"},
|
||||||
|
{"price_pro_yr", "pro"},
|
||||||
|
{"price_unknown", ""},
|
||||||
|
{"", ""},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
if got := cfg.TierForPriceID(tt.priceID); got != tt.want {
|
||||||
|
t.Errorf("TierForPriceID(%q) = %q, want %q", tt.priceID, got, tt.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,17 +2,77 @@ package domain
|
|||||||
|
|
||||||
import "time"
|
import "time"
|
||||||
|
|
||||||
|
type SubscriptionTier string
|
||||||
|
|
||||||
|
const (
|
||||||
|
TierFree SubscriptionTier = "free"
|
||||||
|
TierPremium SubscriptionTier = "premium"
|
||||||
|
TierPro SubscriptionTier = "pro"
|
||||||
|
)
|
||||||
|
|
||||||
|
func IsValidTier(t string) bool {
|
||||||
|
switch SubscriptionTier(t) {
|
||||||
|
case TierFree, TierPremium, TierPro:
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const unlimitedLimit = -1
|
||||||
|
|
||||||
|
type TierLimits struct {
|
||||||
|
MaxAddresses int `json:"max_addresses"`
|
||||||
|
MaxAlertTypes int `json:"max_alert_types"`
|
||||||
|
AllowedChannels []string `json:"allowed_channels"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetTierLimits(tier SubscriptionTier) TierLimits {
|
||||||
|
switch tier {
|
||||||
|
case TierPremium:
|
||||||
|
return TierLimits{
|
||||||
|
MaxAddresses: 3,
|
||||||
|
MaxAlertTypes: 2,
|
||||||
|
AllowedChannels: []string{"email", "discord", "telegram"},
|
||||||
|
}
|
||||||
|
case TierPro:
|
||||||
|
return TierLimits{
|
||||||
|
MaxAddresses: unlimitedLimit,
|
||||||
|
MaxAlertTypes: unlimitedLimit,
|
||||||
|
AllowedChannels: []string{"email", "discord", "telegram", "slack"},
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return TierLimits{
|
||||||
|
MaxAddresses: 1,
|
||||||
|
MaxAlertTypes: 1,
|
||||||
|
AllowedChannels: []string{"email"},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l TierLimits) IsUnlimitedAddresses() bool { return l.MaxAddresses == unlimitedLimit }
|
||||||
|
func (l TierLimits) IsUnlimitedAlertTypes() bool { return l.MaxAlertTypes == unlimitedLimit }
|
||||||
|
|
||||||
|
func (l TierLimits) ChannelAllowed(channel string) bool {
|
||||||
|
for _, c := range l.AllowedChannels {
|
||||||
|
if c == channel {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
type User struct {
|
type User struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
FirebaseUID string `json:"-"`
|
FirebaseUID string `json:"-"`
|
||||||
Email string `json:"email"`
|
Email string `json:"email"`
|
||||||
DisplayName *string `json:"display_name"` //nolint:tagliatelle
|
DisplayName *string `json:"display_name"` //nolint:tagliatelle
|
||||||
StripeCustomerID *string `json:"-"`
|
StripeCustomerID *string `json:"-"`
|
||||||
StripeSubscriptionID *string `json:"-"`
|
StripeSubscriptionID *string `json:"-"`
|
||||||
SubscriptionStatus string `json:"subscription_status"` //nolint:tagliatelle
|
SubscriptionStatus string `json:"subscription_status"` //nolint:tagliatelle
|
||||||
SubscriptionCreatedAt *time.Time `json:"subscription_created_at,omitempty"` //nolint:tagliatelle
|
SubscriptionTier SubscriptionTier `json:"subscription_tier"` //nolint:tagliatelle
|
||||||
CreatedAt time.Time `json:"created_at"` //nolint:tagliatelle
|
SubscriptionCreatedAt *time.Time `json:"subscription_created_at,omitempty"` //nolint:tagliatelle
|
||||||
UpdatedAt time.Time `json:"updated_at"` //nolint:tagliatelle
|
CreatedAt time.Time `json:"created_at"` //nolint:tagliatelle
|
||||||
|
UpdatedAt time.Time `json:"updated_at"` //nolint:tagliatelle
|
||||||
}
|
}
|
||||||
|
|
||||||
type Address struct {
|
type Address struct {
|
||||||
@@ -73,6 +133,8 @@ type AlertRule struct {
|
|||||||
AddressID int `json:"address_id"` //nolint:tagliatelle
|
AddressID int `json:"address_id"` //nolint:tagliatelle
|
||||||
Type AlertType `json:"type"`
|
Type AlertType `json:"type"`
|
||||||
Threshold *float64 `json:"threshold"`
|
Threshold *float64 `json:"threshold"`
|
||||||
|
Minimum *float64 `json:"minimum"`
|
||||||
|
Maximum *float64 `json:"maximum"`
|
||||||
Enabled bool `json:"enabled"`
|
Enabled bool `json:"enabled"`
|
||||||
CreatedAt time.Time `json:"created_at"` //nolint:tagliatelle
|
CreatedAt time.Time `json:"created_at"` //nolint:tagliatelle
|
||||||
}
|
}
|
||||||
|
|||||||
104
backend/internal/domain/types_test.go
Normal file
104
backend/internal/domain/types_test.go
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
package domain
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestIsValidTier(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
valid := []string{"free", "premium", "pro"}
|
||||||
|
for _, tier := range valid {
|
||||||
|
if !IsValidTier(tier) {
|
||||||
|
t.Errorf("expected %q to be valid", tier)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
invalid := []string{"", "basic", "enterprise", "FREE", "Pro"}
|
||||||
|
for _, tier := range invalid {
|
||||||
|
if IsValidTier(tier) {
|
||||||
|
t.Errorf("expected %q to be invalid", tier)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetTierLimits_Free(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
limits := GetTierLimits(TierFree)
|
||||||
|
if limits.MaxAddresses != 1 {
|
||||||
|
t.Errorf("free MaxAddresses = %d, want 1", limits.MaxAddresses)
|
||||||
|
}
|
||||||
|
if limits.MaxAlertTypes != 1 {
|
||||||
|
t.Errorf("free MaxAlertTypes = %d, want 1", limits.MaxAlertTypes)
|
||||||
|
}
|
||||||
|
if len(limits.AllowedChannels) != 1 || limits.AllowedChannels[0] != "email" {
|
||||||
|
t.Errorf("free AllowedChannels = %v, want [email]", limits.AllowedChannels)
|
||||||
|
}
|
||||||
|
if limits.IsUnlimitedAddresses() {
|
||||||
|
t.Error("free should not have unlimited addresses")
|
||||||
|
}
|
||||||
|
if limits.IsUnlimitedAlertTypes() {
|
||||||
|
t.Error("free should not have unlimited alert types")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetTierLimits_Premium(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
limits := GetTierLimits(TierPremium)
|
||||||
|
if limits.MaxAddresses != 3 {
|
||||||
|
t.Errorf("premium MaxAddresses = %d, want 3", limits.MaxAddresses)
|
||||||
|
}
|
||||||
|
if limits.MaxAlertTypes != 2 {
|
||||||
|
t.Errorf("premium MaxAlertTypes = %d, want 2", limits.MaxAlertTypes)
|
||||||
|
}
|
||||||
|
if !limits.ChannelAllowed("email") {
|
||||||
|
t.Error("premium should allow email")
|
||||||
|
}
|
||||||
|
if !limits.ChannelAllowed("discord") {
|
||||||
|
t.Error("premium should allow discord")
|
||||||
|
}
|
||||||
|
if !limits.ChannelAllowed("telegram") {
|
||||||
|
t.Error("premium should allow telegram")
|
||||||
|
}
|
||||||
|
if limits.ChannelAllowed("slack") {
|
||||||
|
t.Error("premium should NOT allow slack")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetTierLimits_Pro(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
limits := GetTierLimits(TierPro)
|
||||||
|
if !limits.IsUnlimitedAddresses() {
|
||||||
|
t.Error("pro should have unlimited addresses")
|
||||||
|
}
|
||||||
|
if !limits.IsUnlimitedAlertTypes() {
|
||||||
|
t.Error("pro should have unlimited alert types")
|
||||||
|
}
|
||||||
|
for _, ch := range []string{"email", "discord", "telegram", "slack"} {
|
||||||
|
if !limits.ChannelAllowed(ch) {
|
||||||
|
t.Errorf("pro should allow %s", ch)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetTierLimits_Unknown(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
limits := GetTierLimits(SubscriptionTier("unknown"))
|
||||||
|
if limits.MaxAddresses != 1 {
|
||||||
|
t.Errorf("unknown tier should default to free limits, got MaxAddresses=%d", limits.MaxAddresses)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestChannelAllowed_NotInList(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
limits := GetTierLimits(TierFree)
|
||||||
|
if limits.ChannelAllowed("discord") {
|
||||||
|
t.Error("free should not allow discord")
|
||||||
|
}
|
||||||
|
if limits.ChannelAllowed("nonexistent") {
|
||||||
|
t.Error("nonexistent channel should not be allowed")
|
||||||
|
}
|
||||||
|
}
|
||||||
22
backend/internal/firebase/users.go
Normal file
22
backend/internal/firebase/users.go
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
package firebase
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"firebase.google.com/go/v4/auth"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SetUserDisabled updates the Firebase user record's disabled flag.
|
||||||
|
func SetUserDisabled(ctx context.Context, uid string, disabled bool) error {
|
||||||
|
if authClient == nil {
|
||||||
|
return fmt.Errorf("firebase auth not initialized") //nolint:err113
|
||||||
|
}
|
||||||
|
if uid == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
params := (&auth.UserToUpdate{}).Disabled(disabled)
|
||||||
|
_, err := authClient.UpdateUser(ctx, uid, params)
|
||||||
|
return err
|
||||||
|
}
|
||||||
@@ -6,29 +6,40 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/kjannette/koin-ping/backend/internal/config"
|
"github.com/kjannette/koin-ping/backend/internal/config"
|
||||||
|
"github.com/kjannette/koin-ping/backend/internal/domain"
|
||||||
"github.com/kjannette/koin-ping/backend/internal/middleware"
|
"github.com/kjannette/koin-ping/backend/internal/middleware"
|
||||||
"github.com/kjannette/koin-ping/backend/internal/models"
|
"github.com/kjannette/koin-ping/backend/internal/models"
|
||||||
)
|
)
|
||||||
|
|
||||||
type AccountHandler struct {
|
type AccountHandler struct {
|
||||||
users *models.UserModel
|
users *models.UserModel
|
||||||
cfg *config.Config
|
addresses *models.AddressModel
|
||||||
|
cfg *config.Config
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewAccountHandler(users *models.UserModel, cfg *config.Config) *AccountHandler {
|
func NewAccountHandler(users *models.UserModel, addresses *models.AddressModel, cfg *config.Config) *AccountHandler {
|
||||||
return &AccountHandler{users: users, cfg: cfg}
|
return &AccountHandler{users: users, addresses: addresses, cfg: cfg}
|
||||||
}
|
}
|
||||||
|
|
||||||
type accountResponse struct {
|
type accountResponse struct {
|
||||||
UserID string `json:"user_id"`
|
UserID string `json:"user_id"`
|
||||||
Email string `json:"email"`
|
Email string `json:"email"`
|
||||||
UserName string `json:"user_name"`
|
UserName string `json:"user_name"`
|
||||||
SubscriptionStatus string `json:"subscription_status"`
|
SubscriptionStatus string `json:"subscription_status"`
|
||||||
SubscriptionPlan string `json:"subscription_plan"`
|
SubscriptionTier string `json:"subscription_tier"`
|
||||||
MemberSince *string `json:"member_since,omitempty"`
|
SubscriptionPlan string `json:"subscription_plan"`
|
||||||
NextBillingDate *string `json:"next_billing_date,omitempty"`
|
TierLimits domain.TierLimits `json:"tier_limits"`
|
||||||
CancelAtPeriodEnd bool `json:"cancel_at_period_end"`
|
AddressCount int `json:"address_count"`
|
||||||
PeriodEndDate *string `json:"period_end_date,omitempty"`
|
MemberSince *string `json:"member_since,omitempty"`
|
||||||
|
NextBillingDate *string `json:"next_billing_date,omitempty"`
|
||||||
|
CancelAtPeriodEnd bool `json:"cancel_at_period_end"`
|
||||||
|
PeriodEndDate *string `json:"period_end_date,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
var tierPlanLabels = map[domain.SubscriptionTier]string{ //nolint:gochecknoglobals
|
||||||
|
domain.TierFree: "Free Trial",
|
||||||
|
domain.TierPremium: "Premium / $8.78 mo",
|
||||||
|
domain.TierPro: "Pro / $16.78 mo",
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *AccountHandler) GetAccount(w http.ResponseWriter, r *http.Request) {
|
func (h *AccountHandler) GetAccount(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -42,12 +53,26 @@ func (h *AccountHandler) GetAccount(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
addrCount, err := h.addresses.CountByUser(r.Context(), userID)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Account: failed to count addresses for %s: %v", userID, err)
|
||||||
|
addrCount = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
planLabel := tierPlanLabels[user.SubscriptionTier]
|
||||||
|
if planLabel == "" {
|
||||||
|
planLabel = "Free Trial"
|
||||||
|
}
|
||||||
|
|
||||||
resp := accountResponse{
|
resp := accountResponse{
|
||||||
UserID: user.ID,
|
UserID: user.ID,
|
||||||
Email: email,
|
Email: email,
|
||||||
UserName: email,
|
UserName: email,
|
||||||
SubscriptionStatus: user.SubscriptionStatus,
|
SubscriptionStatus: user.SubscriptionStatus,
|
||||||
SubscriptionPlan: "monthly/$1.99",
|
SubscriptionTier: string(user.SubscriptionTier),
|
||||||
|
SubscriptionPlan: planLabel,
|
||||||
|
TierLimits: domain.GetTierLimits(user.SubscriptionTier),
|
||||||
|
AddressCount: addrCount,
|
||||||
}
|
}
|
||||||
|
|
||||||
if user.SubscriptionCreatedAt != nil {
|
if user.SubscriptionCreatedAt != nil {
|
||||||
@@ -55,9 +80,6 @@ func (h *AccountHandler) GetAccount(w http.ResponseWriter, r *http.Request) {
|
|||||||
resp.MemberSince = &t
|
resp.MemberSince = &t
|
||||||
}
|
}
|
||||||
|
|
||||||
// MOCKED: NextBillingDate, CancelAtPeriodEnd, PeriodEndDate.
|
|
||||||
// TODO: stripe-go v82 removed Subscription.CurrentPeriodEnd / CancelAtPeriodEnd.
|
|
||||||
// Research SubscriptionItem.CurrentPeriodEnd or use Stripe REST API directly.
|
|
||||||
resp.NextBillingDate = nil
|
resp.NextBillingDate = nil
|
||||||
resp.CancelAtPeriodEnd = false
|
resp.CancelAtPeriodEnd = false
|
||||||
resp.PeriodEndDate = nil
|
resp.PeriodEndDate = nil
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package handlers
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"regexp"
|
"regexp"
|
||||||
@@ -17,10 +18,11 @@ var ethAddressRe = regexp.MustCompile(`^0x[a-fA-F0-9]{40}$`)
|
|||||||
|
|
||||||
type AddressHandler struct {
|
type AddressHandler struct {
|
||||||
addresses *models.AddressModel
|
addresses *models.AddressModel
|
||||||
|
users *models.UserModel
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewAddressHandler(addresses *models.AddressModel) *AddressHandler {
|
func NewAddressHandler(addresses *models.AddressModel, users *models.UserModel) *AddressHandler {
|
||||||
return &AddressHandler{addresses: addresses}
|
return &AddressHandler{addresses: addresses, users: users}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *AddressHandler) Create(w http.ResponseWriter, r *http.Request) {
|
func (h *AddressHandler) Create(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -49,6 +51,28 @@ func (h *AddressHandler) Create(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
user, err := h.users.GetByID(r.Context(), userID)
|
||||||
|
if err != nil || user == nil {
|
||||||
|
log.Printf("Failed to get user %s for tier check: %v", userID, err)
|
||||||
|
writeError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "Failed to verify account")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
limits := domain.GetTierLimits(user.SubscriptionTier)
|
||||||
|
if !limits.IsUnlimitedAddresses() {
|
||||||
|
count, err := h.addresses.CountByUser(r.Context(), userID)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Failed to count addresses for user %s: %v", userID, err)
|
||||||
|
writeError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "Failed to create address")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if count >= limits.MaxAddresses {
|
||||||
|
writeError(w, http.StatusForbidden, "TIER_LIMIT_REACHED",
|
||||||
|
fmt.Sprintf("Your %s plan allows %d address(es). Upgrade to track more.", user.SubscriptionTier, limits.MaxAddresses))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
log.Printf("User %s creating address: %s", userID, body.Address)
|
log.Printf("User %s creating address: %s", userID, body.Address)
|
||||||
|
|
||||||
addr, err := h.addresses.Create(r.Context(), userID, body.Address, body.Label)
|
addr, err := h.addresses.Create(r.Context(), userID, body.Address, body.Label)
|
||||||
|
|||||||
@@ -19,10 +19,11 @@ var errThresholdFormat = errors.New("unsupported threshold format")
|
|||||||
type AlertRuleHandler struct {
|
type AlertRuleHandler struct {
|
||||||
alertRules *models.AlertRuleModel
|
alertRules *models.AlertRuleModel
|
||||||
addresses *models.AddressModel
|
addresses *models.AddressModel
|
||||||
|
users *models.UserModel
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewAlertRuleHandler(alertRules *models.AlertRuleModel, addresses *models.AddressModel) *AlertRuleHandler {
|
func NewAlertRuleHandler(alertRules *models.AlertRuleModel, addresses *models.AddressModel, users *models.UserModel) *AlertRuleHandler {
|
||||||
return &AlertRuleHandler{alertRules: alertRules, addresses: addresses}
|
return &AlertRuleHandler{alertRules: alertRules, addresses: addresses, users: users}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *AlertRuleHandler) Create(w http.ResponseWriter, r *http.Request) {
|
func (h *AlertRuleHandler) Create(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -37,6 +38,8 @@ func (h *AlertRuleHandler) Create(w http.ResponseWriter, r *http.Request) {
|
|||||||
var body struct {
|
var body struct {
|
||||||
Type string `json:"type"`
|
Type string `json:"type"`
|
||||||
Threshold json.RawMessage `json:"threshold"`
|
Threshold json.RawMessage `json:"threshold"`
|
||||||
|
Minimum json.RawMessage `json:"minimum"`
|
||||||
|
Maximum json.RawMessage `json:"maximum"`
|
||||||
}
|
}
|
||||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||||
log.Printf("Failed to decode alert request body: %v", err)
|
log.Printf("Failed to decode alert request body: %v", err)
|
||||||
@@ -53,6 +56,40 @@ func (h *AlertRuleHandler) Create(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
minimum, err := parseThreshold(body.Minimum)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Failed to parse minimum: %v", err)
|
||||||
|
writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "minimum must be a valid number")
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
maximum, err := parseThreshold(body.Maximum)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Failed to parse maximum: %v", err)
|
||||||
|
writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "maximum must be a valid number")
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if minimum != nil && *minimum < 0 {
|
||||||
|
writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "minimum must be non-negative")
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if maximum != nil && *maximum < 0 {
|
||||||
|
writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "maximum must be non-negative")
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if minimum != nil && maximum != nil && *minimum > *maximum {
|
||||||
|
writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "minimum must not exceed maximum")
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
log.Printf("User %s creating alert: type=%s, addressID=%d", userID, body.Type, addressID)
|
log.Printf("User %s creating alert: type=%s, addressID=%d", userID, body.Type, addressID)
|
||||||
|
|
||||||
if body.Type == "" {
|
if body.Type == "" {
|
||||||
@@ -96,7 +133,29 @@ func (h *AlertRuleHandler) Create(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
newAlert, err := h.alertRules.Create(r.Context(), addressID, alertType, threshold)
|
user, userErr := h.users.GetByID(r.Context(), userID)
|
||||||
|
if userErr != nil || user == nil {
|
||||||
|
log.Printf("Failed to get user %s for tier check: %v", userID, userErr)
|
||||||
|
writeError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "Failed to verify account")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
limits := domain.GetTierLimits(user.SubscriptionTier)
|
||||||
|
if !limits.IsUnlimitedAlertTypes() {
|
||||||
|
typeCount, countErr := h.alertRules.CountDistinctTypesByAddress(r.Context(), addressID)
|
||||||
|
if countErr != nil {
|
||||||
|
log.Printf("Failed to count alert types for address %d: %v", addressID, countErr)
|
||||||
|
writeError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "Failed to create alert rule")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if typeCount >= limits.MaxAlertTypes {
|
||||||
|
writeError(w, http.StatusForbidden, "TIER_LIMIT_REACHED",
|
||||||
|
fmt.Sprintf("Your %s plan allows %d alert type(s) per address. Upgrade for more.", user.SubscriptionTier, limits.MaxAlertTypes))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
newAlert, err := h.alertRules.Create(r.Context(), addressID, alertType, threshold, minimum, maximum)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("Error creating alert rule: %v", err)
|
log.Printf("Error creating alert rule: %v", err)
|
||||||
writeError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "Failed to create alert rule")
|
writeError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "Failed to create alert rule")
|
||||||
@@ -182,7 +241,7 @@ func (h *AlertRuleHandler) ListByAddress(w http.ResponseWriter, r *http.Request)
|
|||||||
writeJSON(w, http.StatusOK, alerts)
|
writeJSON(w, http.StatusOK, alerts)
|
||||||
}
|
}
|
||||||
|
|
||||||
// UpdateStatus handles PATCH requests to enable or disable an alert rule.
|
// UpdateStatus handles PATCH requests to enable/disable an alert rule and/or update min/max thresholds.
|
||||||
func (h *AlertRuleHandler) UpdateStatus(w http.ResponseWriter, r *http.Request) {
|
func (h *AlertRuleHandler) UpdateStatus(w http.ResponseWriter, r *http.Request) {
|
||||||
userID := middleware.GetUserID(r.Context())
|
userID := middleware.GetUserID(r.Context())
|
||||||
alertID, ok := parseIntParam(r.PathValue("alertId"))
|
alertID, ok := parseIntParam(r.PathValue("alertId"))
|
||||||
@@ -193,7 +252,10 @@ func (h *AlertRuleHandler) UpdateStatus(w http.ResponseWriter, r *http.Request)
|
|||||||
}
|
}
|
||||||
|
|
||||||
var body struct {
|
var body struct {
|
||||||
Enabled *bool `json:"enabled"`
|
Enabled *bool `json:"enabled"`
|
||||||
|
Minimum json.RawMessage `json:"minimum"`
|
||||||
|
Maximum json.RawMessage `json:"maximum"`
|
||||||
|
UpdateMinMax bool `json:"update_min_max"` //nolint:tagliatelle
|
||||||
}
|
}
|
||||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||||
log.Printf("Failed to decode update request body: %v", err)
|
log.Printf("Failed to decode update request body: %v", err)
|
||||||
@@ -204,8 +266,8 @@ func (h *AlertRuleHandler) UpdateStatus(w http.ResponseWriter, r *http.Request)
|
|||||||
|
|
||||||
log.Printf("User %s updating alert ID: %d", userID, alertID)
|
log.Printf("User %s updating alert ID: %d", userID, alertID)
|
||||||
|
|
||||||
if body.Enabled == nil {
|
if body.Enabled == nil && !body.UpdateMinMax {
|
||||||
writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "enabled must be a boolean value")
|
writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "must provide enabled (boolean) or update_min_max with minimum/maximum values")
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -224,15 +286,64 @@ func (h *AlertRuleHandler) UpdateStatus(w http.ResponseWriter, r *http.Request)
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
updated, err := h.alertRules.UpdateEnabled(r.Context(), alertID, *body.Enabled)
|
var updated *domain.AlertRule
|
||||||
if err != nil {
|
|
||||||
log.Printf("Error updating alert: %v", err)
|
|
||||||
writeError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "Failed to update alert")
|
|
||||||
|
|
||||||
return
|
if body.UpdateMinMax {
|
||||||
|
minimum, parseErr := parseThreshold(body.Minimum)
|
||||||
|
if parseErr != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "minimum must be a valid number")
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
maximum, parseErr := parseThreshold(body.Maximum)
|
||||||
|
if parseErr != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "maximum must be a valid number")
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if minimum != nil && *minimum < 0 {
|
||||||
|
writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "minimum must be non-negative")
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if maximum != nil && *maximum < 0 {
|
||||||
|
writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "maximum must be non-negative")
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if minimum != nil && maximum != nil && *minimum > *maximum {
|
||||||
|
writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "minimum must not exceed maximum")
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
updated, err = h.alertRules.UpdateThresholds(r.Context(), alertID, minimum, maximum)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Error updating alert thresholds: %v", err)
|
||||||
|
writeError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "Failed to update alert")
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("Alert %d thresholds updated: min=%v, max=%v", alertID, minimum, maximum)
|
||||||
|
}
|
||||||
|
|
||||||
|
if body.Enabled != nil {
|
||||||
|
updated, err = h.alertRules.UpdateEnabled(r.Context(), alertID, *body.Enabled)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Error updating alert: %v", err)
|
||||||
|
writeError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "Failed to update alert")
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("Alert %d updated: enabled=%v", alertID, *body.Enabled)
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Printf("Alert %d updated: enabled=%v", alertID, *body.Enabled)
|
|
||||||
writeJSON(w, http.StatusOK, updated)
|
writeJSON(w, http.StatusOK, updated)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -18,11 +18,12 @@ var emailRe = regexp.MustCompile(`^[^\s@]+@[^\s@]+\.[^\s@]+$`)
|
|||||||
|
|
||||||
type NotificationConfigHandler struct {
|
type NotificationConfigHandler struct {
|
||||||
configs *models.NotificationConfigModel
|
configs *models.NotificationConfigModel
|
||||||
|
users *models.UserModel
|
||||||
cfg *config.Config
|
cfg *config.Config
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewNotificationConfigHandler(configs *models.NotificationConfigModel, cfg *config.Config) *NotificationConfigHandler {
|
func NewNotificationConfigHandler(configs *models.NotificationConfigModel, users *models.UserModel, cfg *config.Config) *NotificationConfigHandler {
|
||||||
return &NotificationConfigHandler{configs: configs, cfg: cfg}
|
return &NotificationConfigHandler{configs: configs, users: users, cfg: cfg}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *NotificationConfigHandler) GetConfig(w http.ResponseWriter, r *http.Request) {
|
func (h *NotificationConfigHandler) GetConfig(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -95,6 +96,26 @@ func (h *NotificationConfigHandler) UpdateConfig(w http.ResponseWriter, r *http.
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
user, userErr := h.users.GetByID(r.Context(), userID)
|
||||||
|
if userErr != nil || user == nil {
|
||||||
|
log.Printf("Failed to get user %s for tier check: %v", userID, userErr)
|
||||||
|
writeError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "Failed to verify account")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
limits := domain.GetTierLimits(user.SubscriptionTier)
|
||||||
|
|
||||||
|
if !limits.ChannelAllowed("discord") {
|
||||||
|
body.DiscordWebhookURL = nil
|
||||||
|
}
|
||||||
|
if !limits.ChannelAllowed("telegram") {
|
||||||
|
body.TelegramBotToken = nil
|
||||||
|
body.TelegramChatID = nil
|
||||||
|
}
|
||||||
|
if !limits.ChannelAllowed("slack") {
|
||||||
|
body.SlackWebhookURL = nil
|
||||||
|
}
|
||||||
|
|
||||||
enabled := true
|
enabled := true
|
||||||
if body.NotificationEnabled != nil {
|
if body.NotificationEnabled != nil {
|
||||||
enabled = *body.NotificationEnabled
|
enabled = *body.NotificationEnabled
|
||||||
|
|||||||
@@ -1,37 +1,128 @@
|
|||||||
package handlers
|
package handlers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
|
kpfirebase "github.com/kjannette/koin-ping/backend/internal/firebase"
|
||||||
|
"github.com/kjannette/koin-ping/backend/internal/config"
|
||||||
|
"github.com/kjannette/koin-ping/backend/internal/domain"
|
||||||
|
"github.com/kjannette/koin-ping/backend/internal/middleware"
|
||||||
|
"github.com/kjannette/koin-ping/backend/internal/models"
|
||||||
"github.com/stripe/stripe-go/v82"
|
"github.com/stripe/stripe-go/v82"
|
||||||
portalsession "github.com/stripe/stripe-go/v82/billingportal/session"
|
portalsession "github.com/stripe/stripe-go/v82/billingportal/session"
|
||||||
checkoutsession "github.com/stripe/stripe-go/v82/checkout/session"
|
checkoutsession "github.com/stripe/stripe-go/v82/checkout/session"
|
||||||
"github.com/stripe/stripe-go/v82/webhook"
|
"github.com/stripe/stripe-go/v82/webhook"
|
||||||
|
|
||||||
"github.com/kjannette/koin-ping/backend/internal/config"
|
|
||||||
"github.com/kjannette/koin-ping/backend/internal/middleware"
|
|
||||||
"github.com/kjannette/koin-ping/backend/internal/models"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const webhookMaxBodyBytes = 65536
|
const webhookMaxBodyBytes = 65536
|
||||||
|
|
||||||
type StripeHandler struct {
|
type StripeHandler struct {
|
||||||
users *models.UserModel
|
users *models.UserModel
|
||||||
cfg *config.Config
|
alerts *models.AlertRuleModel
|
||||||
|
cfg *config.Config
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewStripeHandler(users *models.UserModel, cfg *config.Config) *StripeHandler {
|
func NewStripeHandler(users *models.UserModel, alerts *models.AlertRuleModel, cfg *config.Config) *StripeHandler {
|
||||||
stripe.Key = cfg.StripeSecretKey
|
stripe.Key = cfg.StripeSecretKey
|
||||||
return &StripeHandler{users: users, cfg: cfg}
|
return &StripeHandler{users: users, alerts: alerts, cfg: cfg}
|
||||||
}
|
}
|
||||||
|
|
||||||
// CreateCheckoutSession creates a Stripe Checkout session for the monthly subscription.
|
func (h *StripeHandler) ensureUserFirebaseAndAlertsEnabled(ctx context.Context, localUserID string) {
|
||||||
|
user, err := h.users.GetByID(ctx, localUserID)
|
||||||
|
if err != nil || user == nil || user.FirebaseUID == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if firebaseErr := kpfirebase.SetUserDisabled(ctx, user.FirebaseUID, false); firebaseErr != nil {
|
||||||
|
log.Printf("Billing access restore: firebase enable failed for user %s: %v", localUserID, firebaseErr)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
n, alertsErr := h.alerts.EnableAllForUser(ctx, localUserID)
|
||||||
|
if alertsErr != nil {
|
||||||
|
log.Printf("Billing access restore: enable alerts failed for user %s: %v", localUserID, alertsErr)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("Billing access restored: user %s, %d alert rules enabled", localUserID, n)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *StripeHandler) restorePaidSubscriptionAccess(ctx context.Context, stripeCustomerID, status string) {
|
||||||
|
if stripeCustomerID == "" || (status != "active" && status != "trialing") {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
u, err := h.users.GetByStripeCustomerID(ctx, stripeCustomerID)
|
||||||
|
if err != nil || u == nil {
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("restorePaidSubscriptionAccess: lookup %s: %v", stripeCustomerID, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
h.ensureUserFirebaseAndAlertsEnabled(ctx, u.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *StripeHandler) priceIDForTier(tier domain.SubscriptionTier, interval string) (string, error) {
|
||||||
|
if interval == "annual" {
|
||||||
|
switch tier {
|
||||||
|
case domain.TierPremium:
|
||||||
|
return h.cfg.StripePriceIDPremiumAnnual, nil
|
||||||
|
case domain.TierPro:
|
||||||
|
return h.cfg.StripePriceIDProAnnual, nil
|
||||||
|
default:
|
||||||
|
return "", fmt.Errorf("no Stripe price for tier %q", tier) //nolint:err113
|
||||||
|
}
|
||||||
|
}
|
||||||
|
switch tier {
|
||||||
|
case domain.TierPremium:
|
||||||
|
return h.cfg.StripePriceIDPremium, nil
|
||||||
|
case domain.TierPro:
|
||||||
|
return h.cfg.StripePriceIDPro, nil
|
||||||
|
default:
|
||||||
|
return "", fmt.Errorf("no Stripe price for tier %q", tier) //nolint:err113
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateCheckoutSession creates a Stripe Checkout session for the selected tier.
|
||||||
func (h *StripeHandler) CreateCheckoutSession(w http.ResponseWriter, r *http.Request) {
|
func (h *StripeHandler) CreateCheckoutSession(w http.ResponseWriter, r *http.Request) {
|
||||||
userID := middleware.GetUserID(r.Context())
|
userID := middleware.GetUserID(r.Context())
|
||||||
|
|
||||||
|
var body struct {
|
||||||
|
Tier string `json:"tier"`
|
||||||
|
Interval string `json:"interval"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "BAD_REQUEST", "Invalid request body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if body.Tier == "" {
|
||||||
|
body.Tier = "premium"
|
||||||
|
}
|
||||||
|
if body.Interval == "" {
|
||||||
|
body.Interval = "annual"
|
||||||
|
}
|
||||||
|
|
||||||
|
tier := domain.SubscriptionTier(body.Tier)
|
||||||
|
if tier != domain.TierPremium && tier != domain.TierPro {
|
||||||
|
writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "Tier must be 'premium' or 'pro'")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
priceID, err := h.priceIDForTier(tier, body.Interval)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
user, err := h.users.GetByID(r.Context(), userID)
|
user, err := h.users.GetByID(r.Context(), userID)
|
||||||
if err != nil || user == nil {
|
if err != nil || user == nil {
|
||||||
log.Printf("Failed to get user %s: %v", userID, err)
|
log.Printf("Failed to get user %s: %v", userID, err)
|
||||||
@@ -43,16 +134,18 @@ func (h *StripeHandler) CreateCheckoutSession(w http.ResponseWriter, r *http.Req
|
|||||||
Mode: stripe.String(string(stripe.CheckoutSessionModeSubscription)),
|
Mode: stripe.String(string(stripe.CheckoutSessionModeSubscription)),
|
||||||
LineItems: []*stripe.CheckoutSessionLineItemParams{
|
LineItems: []*stripe.CheckoutSessionLineItemParams{
|
||||||
{
|
{
|
||||||
Price: stripe.String(h.cfg.StripePriceID),
|
Price: stripe.String(priceID),
|
||||||
Quantity: stripe.Int64(1),
|
Quantity: stripe.Int64(1),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
SuccessURL: stripe.String(h.cfg.FrontendURL + "/subscribe?payment=success&session_id={CHECKOUT_SESSION_ID}"),
|
SuccessURL: stripe.String(h.cfg.FrontendURL + "/subscribe/return/{CHECKOUT_SESSION_ID}"),
|
||||||
CancelURL: stripe.String(h.cfg.FrontendURL + "/subscribe?payment=cancelled"),
|
CancelURL: stripe.String(h.cfg.FrontendURL + "/subscribe?payment=cancelled"),
|
||||||
ClientReferenceID: stripe.String(userID),
|
ClientReferenceID: stripe.String(userID),
|
||||||
CustomerEmail: stripe.String(user.Email),
|
CustomerEmail: stripe.String(user.Email),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
params.AddMetadata("tier", string(tier))
|
||||||
|
|
||||||
if user.StripeCustomerID != nil && *user.StripeCustomerID != "" {
|
if user.StripeCustomerID != nil && *user.StripeCustomerID != "" {
|
||||||
params.Customer = user.StripeCustomerID
|
params.Customer = user.StripeCustomerID
|
||||||
params.CustomerEmail = nil
|
params.CustomerEmail = nil
|
||||||
@@ -80,14 +173,14 @@ func (h *StripeHandler) GetSubscriptionStatus(w http.ResponseWriter, r *http.Req
|
|||||||
}
|
}
|
||||||
|
|
||||||
writeJSON(w, http.StatusOK, map[string]any{
|
writeJSON(w, http.StatusOK, map[string]any{
|
||||||
"subscription_status": user.SubscriptionStatus,
|
"subscription_status": user.SubscriptionStatus,
|
||||||
|
"subscription_tier": user.SubscriptionTier,
|
||||||
"subscription_created_at": user.SubscriptionCreatedAt,
|
"subscription_created_at": user.SubscriptionCreatedAt,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// VerifyCheckoutSession retrieves a completed checkout session from Stripe,
|
// VerifyCheckoutSession retrieves a completed checkout session from Stripe,
|
||||||
// confirms payment, and activates the user's subscription in the database.
|
// confirms payment, and activates the user's subscription in the database.
|
||||||
// This is the primary activation path; webhooks serve as a backup.
|
|
||||||
func (h *StripeHandler) VerifyCheckoutSession(w http.ResponseWriter, r *http.Request) {
|
func (h *StripeHandler) VerifyCheckoutSession(w http.ResponseWriter, r *http.Request) {
|
||||||
userID := middleware.GetUserID(r.Context())
|
userID := middleware.GetUserID(r.Context())
|
||||||
|
|
||||||
@@ -106,7 +199,7 @@ func (h *StripeHandler) VerifyCheckoutSession(w http.ResponseWriter, r *http.Req
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if s.ClientReferenceID != userID {
|
if s.ClientReferenceID != "" && s.ClientReferenceID != userID {
|
||||||
writeError(w, http.StatusForbidden, "FORBIDDEN", "Session does not belong to this user")
|
writeError(w, http.StatusForbidden, "FORBIDDEN", "Session does not belong to this user")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -116,6 +209,11 @@ func (h *StripeHandler) VerifyCheckoutSession(w http.ResponseWriter, r *http.Req
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
tier := domain.TierPremium
|
||||||
|
if t, ok := s.Metadata["tier"]; ok && domain.IsValidTier(t) {
|
||||||
|
tier = domain.SubscriptionTier(t)
|
||||||
|
}
|
||||||
|
|
||||||
customerID := ""
|
customerID := ""
|
||||||
if s.Customer != nil {
|
if s.Customer != nil {
|
||||||
customerID = s.Customer.ID
|
customerID = s.Customer.ID
|
||||||
@@ -131,13 +229,100 @@ func (h *StripeHandler) VerifyCheckoutSession(w http.ResponseWriter, r *http.Req
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if subscriptionID != "" && customerID != "" {
|
if subscriptionID != "" && customerID != "" {
|
||||||
if err := h.users.ActivateSubscription(r.Context(), customerID, subscriptionID, "active"); err != nil {
|
if err := h.users.ActivateSubscription(r.Context(), customerID, subscriptionID, "active", tier); err != nil {
|
||||||
log.Printf("VerifyCheckout: failed to activate subscription: %v", err)
|
log.Printf("VerifyCheckout: failed to activate subscription: %v", err)
|
||||||
|
} else {
|
||||||
|
h.restorePaidSubscriptionAccess(r.Context(), customerID, "active")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Printf("Checkout verified for user %s, customer %s, subscription %s", userID, customerID, subscriptionID)
|
log.Printf("Checkout verified for user %s, customer %s, subscription %s, tier %s", userID, customerID, subscriptionID, tier)
|
||||||
writeJSON(w, http.StatusOK, map[string]string{"subscription_status": "active"})
|
writeJSON(w, http.StatusOK, map[string]string{
|
||||||
|
"subscription_status": "active",
|
||||||
|
"subscription_tier": string(tier),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ActivateFreeTier sets the user to the free tier without Stripe involvement.
|
||||||
|
func (h *StripeHandler) ActivateFreeTier(w http.ResponseWriter, r *http.Request) {
|
||||||
|
userID := middleware.GetUserID(r.Context())
|
||||||
|
|
||||||
|
if err := h.users.ActivateFreeTier(r.Context(), userID); err != nil {
|
||||||
|
log.Printf("ActivateFreeTier: failed for user %s: %v", userID, err)
|
||||||
|
writeError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "Failed to activate free tier")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
h.ensureUserFirebaseAndAlertsEnabled(r.Context(), userID)
|
||||||
|
|
||||||
|
log.Printf("Free tier activated for user %s", userID)
|
||||||
|
writeJSON(w, http.StatusOK, map[string]string{
|
||||||
|
"subscription_status": "active",
|
||||||
|
"subscription_tier": "free",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateOnboardingCheckout creates a Stripe Checkout session for a user who
|
||||||
|
// has not yet created an account. This is a public endpoint (no auth required).
|
||||||
|
// The Firebase account is created on the frontend only after payment succeeds.
|
||||||
|
func (h *StripeHandler) CreateOnboardingCheckout(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var body struct {
|
||||||
|
Email string `json:"email"`
|
||||||
|
Tier string `json:"tier"`
|
||||||
|
Interval string `json:"interval"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "BAD_REQUEST", "Invalid request body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if body.Email == "" {
|
||||||
|
writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "Email is required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if body.Tier == "" {
|
||||||
|
body.Tier = "premium"
|
||||||
|
}
|
||||||
|
if body.Interval == "" {
|
||||||
|
body.Interval = "annual"
|
||||||
|
}
|
||||||
|
|
||||||
|
tier := domain.SubscriptionTier(body.Tier)
|
||||||
|
if tier != domain.TierPremium && tier != domain.TierPro {
|
||||||
|
writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "Tier must be 'premium' or 'pro'")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
priceID, err := h.priceIDForTier(tier, body.Interval)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
params := &stripe.CheckoutSessionParams{
|
||||||
|
Mode: stripe.String(string(stripe.CheckoutSessionModeSubscription)),
|
||||||
|
LineItems: []*stripe.CheckoutSessionLineItemParams{
|
||||||
|
{
|
||||||
|
Price: stripe.String(priceID),
|
||||||
|
Quantity: stripe.Int64(1),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
SuccessURL: stripe.String(h.cfg.FrontendURL + "/subscribe/return/{CHECKOUT_SESSION_ID}"),
|
||||||
|
CancelURL: stripe.String(h.cfg.FrontendURL + "/subscribe?payment=cancelled"),
|
||||||
|
CustomerEmail: stripe.String(body.Email),
|
||||||
|
}
|
||||||
|
params.AddMetadata("tier", string(tier))
|
||||||
|
params.AddMetadata("onboarding", "true")
|
||||||
|
|
||||||
|
s, err := checkoutsession.New(params)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Failed to create onboarding checkout session: %v", err)
|
||||||
|
writeError(w, http.StatusInternalServerError, "STRIPE_ERROR", "Failed to create checkout session")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, map[string]string{"url": s.URL})
|
||||||
}
|
}
|
||||||
|
|
||||||
// CreatePortalSession creates a Stripe Billing Portal session so the user can
|
// CreatePortalSession creates a Stripe Billing Portal session so the user can
|
||||||
@@ -173,7 +358,6 @@ func (h *StripeHandler) CreatePortalSession(w http.ResponseWriter, r *http.Reque
|
|||||||
}
|
}
|
||||||
|
|
||||||
// HandleWebhook processes incoming Stripe webhook events.
|
// HandleWebhook processes incoming Stripe webhook events.
|
||||||
// This endpoint must NOT require authentication (Stripe calls it directly).
|
|
||||||
func (h *StripeHandler) HandleWebhook(w http.ResponseWriter, r *http.Request) {
|
func (h *StripeHandler) HandleWebhook(w http.ResponseWriter, r *http.Request) {
|
||||||
payload, err := io.ReadAll(io.LimitReader(r.Body, webhookMaxBodyBytes))
|
payload, err := io.ReadAll(io.LimitReader(r.Body, webhookMaxBodyBytes))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -217,6 +401,11 @@ func (h *StripeHandler) handleCheckoutCompleted(r *http.Request, event stripe.Ev
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
tier := domain.TierPremium
|
||||||
|
if t, ok := session.Metadata["tier"]; ok && domain.IsValidTier(t) {
|
||||||
|
tier = domain.SubscriptionTier(t)
|
||||||
|
}
|
||||||
|
|
||||||
customerID := ""
|
customerID := ""
|
||||||
if session.Customer != nil {
|
if session.Customer != nil {
|
||||||
customerID = session.Customer.ID
|
customerID = session.Customer.ID
|
||||||
@@ -233,12 +422,14 @@ func (h *StripeHandler) handleCheckoutCompleted(r *http.Request, event stripe.Ev
|
|||||||
}
|
}
|
||||||
|
|
||||||
if subscriptionID != "" && customerID != "" {
|
if subscriptionID != "" && customerID != "" {
|
||||||
if err := h.users.ActivateSubscription(r.Context(), customerID, subscriptionID, "active"); err != nil {
|
if err := h.users.ActivateSubscription(r.Context(), customerID, subscriptionID, "active", tier); err != nil {
|
||||||
log.Printf("Failed to activate subscription: %v", err)
|
log.Printf("Failed to activate subscription: %v", err)
|
||||||
|
} else {
|
||||||
|
h.restorePaidSubscriptionAccess(r.Context(), customerID, "active")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Printf("Checkout completed for user %s, customer %s, subscription %s", userID, customerID, subscriptionID)
|
log.Printf("Checkout completed for user %s, customer %s, subscription %s, tier %s", userID, customerID, subscriptionID, tier)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *StripeHandler) handleSubscriptionUpdated(r *http.Request, event stripe.Event) {
|
func (h *StripeHandler) handleSubscriptionUpdated(r *http.Request, event stripe.Event) {
|
||||||
@@ -257,11 +448,30 @@ func (h *StripeHandler) handleSubscriptionUpdated(r *http.Request, event stripe.
|
|||||||
}
|
}
|
||||||
|
|
||||||
status := string(sub.Status)
|
status := string(sub.Status)
|
||||||
if err := h.users.ActivateSubscription(r.Context(), customerID, sub.ID, status); err != nil {
|
|
||||||
log.Printf("Failed to update subscription status: %v", err)
|
// Detect tier from the subscription's current price so that
|
||||||
|
// upgrades/downgrades via the Stripe portal are reflected.
|
||||||
|
tier := domain.TierPremium
|
||||||
|
if sub.Items != nil {
|
||||||
|
for _, item := range sub.Items.Data {
|
||||||
|
if item.Price != nil {
|
||||||
|
if t := h.cfg.TierForPriceID(item.Price.ID); t != "" {
|
||||||
|
tier = domain.SubscriptionTier(t)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Printf("Subscription %s updated to %s for customer %s", sub.ID, status, customerID)
|
if err := h.users.ActivateSubscription(r.Context(), customerID, sub.ID, status, tier); err != nil {
|
||||||
|
log.Printf("Failed to update subscription: %v", err)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
h.restorePaidSubscriptionAccess(r.Context(), customerID, status)
|
||||||
|
|
||||||
|
log.Printf("Subscription %s updated to %s (tier %s) for customer %s", sub.ID, status, tier, customerID)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *StripeHandler) handleSubscriptionDeleted(r *http.Request, event stripe.Event) {
|
func (h *StripeHandler) handleSubscriptionDeleted(r *http.Request, event stripe.Event) {
|
||||||
|
|||||||
111
backend/internal/handlers/support.go
Normal file
111
backend/internal/handlers/support.go
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"net/mail"
|
||||||
|
"os"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/kjannette/koin-ping/backend/internal/config"
|
||||||
|
"github.com/kjannette/koin-ping/backend/internal/notifications"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
maxSupportDescriptionLen = 8000
|
||||||
|
maxSupportEmailLen = 320
|
||||||
|
)
|
||||||
|
|
||||||
|
var descriptionAllowedRE = regexp.MustCompile(`^[a-zA-Z0-9\s]+$`)
|
||||||
|
|
||||||
|
type supportRequestBody struct {
|
||||||
|
Email string `json:"email"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SupportHandler accepts authenticated support form submissions and emails the inbox.
|
||||||
|
type SupportHandler struct {
|
||||||
|
cfg *config.Config
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewSupportHandler(cfg *config.Config) *SupportHandler {
|
||||||
|
return &SupportHandler{cfg: cfg}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Submit handles POST /support.
|
||||||
|
func (h *SupportHandler) Submit(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var body supportRequestBody
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "INVALID_JSON", "Request body must be JSON")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
email := strings.TrimSpace(body.Email)
|
||||||
|
description := strings.TrimSpace(body.Description)
|
||||||
|
|
||||||
|
if len(email) > maxSupportEmailLen {
|
||||||
|
writeError(w, http.StatusBadRequest, "INVALID_EMAIL", "Email is too long")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
parsed, err := mail.ParseAddress(email)
|
||||||
|
if err != nil || parsed.Address == "" {
|
||||||
|
writeError(w, http.StatusBadRequest, "INVALID_EMAIL", "Invalid email address")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
canonicalEmail := parsed.Address
|
||||||
|
|
||||||
|
if description == "" {
|
||||||
|
writeError(w, http.StatusBadRequest, "INVALID_DESCRIPTION", "Description is required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(description) > maxSupportDescriptionLen {
|
||||||
|
writeError(w, http.StatusBadRequest, "INVALID_DESCRIPTION", "Description is too long")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if !descriptionAllowedRE.MatchString(description) {
|
||||||
|
writeError(w, http.StatusBadRequest, "INVALID_DESCRIPTION",
|
||||||
|
"Description may only contain letters, numbers, and whitespace")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
subject := time.Now().UTC().Format(time.RFC3339) + " - new support issue - koinp.ing"
|
||||||
|
plainBody := "Contact email: " + canonicalEmail + "\n\nIssue description:\n" + description
|
||||||
|
|
||||||
|
// Local dev: skip Resend when SUPPORT_DEV_SKIP_EMAIL=1 (see backend logs for payload).
|
||||||
|
if strings.EqualFold(h.cfg.NodeEnv, "development") && os.Getenv("SUPPORT_DEV_SKIP_EMAIL") == "1" {
|
||||||
|
log.Printf("[dev] SUPPORT_DEV_SKIP_EMAIL: skipping Resend; to=%s subject=%s", h.cfg.SupportInboxEmail, subject)
|
||||||
|
log.Printf("[dev] SUPPORT_DEV_SKIP_EMAIL body:\n%s", plainBody)
|
||||||
|
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if h.cfg.ResendAPIKey == "" {
|
||||||
|
writeError(w, http.StatusServiceUnavailable, "EMAIL_UNAVAILABLE", "Support email is not configured")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := notifications.SendSupportEmail(
|
||||||
|
h.cfg.ResendAPIKey,
|
||||||
|
h.cfg.EmailFrom,
|
||||||
|
h.cfg.SupportInboxEmail,
|
||||||
|
subject,
|
||||||
|
plainBody,
|
||||||
|
); err != nil {
|
||||||
|
log.Printf("support Submit: send email: %v", err)
|
||||||
|
msg := "Could not send email. Confirm RESEND_API_KEY and that EMAIL_FROM uses a domain verified in Resend."
|
||||||
|
if strings.EqualFold(h.cfg.NodeEnv, "development") {
|
||||||
|
msg = "Email send failed (development): " + err.Error()
|
||||||
|
}
|
||||||
|
writeError(w, http.StatusInternalServerError, "SEND_FAILED", msg)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
||||||
|
}
|
||||||
@@ -17,6 +17,7 @@ type contextKey string
|
|||||||
const (
|
const (
|
||||||
UserIDKey contextKey = "user_id"
|
UserIDKey contextKey = "user_id"
|
||||||
UserEmailKey contextKey = "user_email"
|
UserEmailKey contextKey = "user_email"
|
||||||
|
UserTierKey contextKey = "user_tier"
|
||||||
)
|
)
|
||||||
|
|
||||||
type errorResponse struct {
|
type errorResponse struct {
|
||||||
@@ -94,6 +95,7 @@ func Authenticate(userModel *models.UserModel) func(http.Handler) http.Handler {
|
|||||||
|
|
||||||
ctx := context.WithValue(r.Context(), UserIDKey, user.ID)
|
ctx := context.WithValue(r.Context(), UserIDKey, user.ID)
|
||||||
ctx = context.WithValue(ctx, UserEmailKey, email)
|
ctx = context.WithValue(ctx, UserEmailKey, email)
|
||||||
|
ctx = context.WithValue(ctx, UserTierKey, string(user.SubscriptionTier))
|
||||||
|
|
||||||
next.ServeHTTP(w, r.WithContext(ctx))
|
next.ServeHTTP(w, r.WithContext(ctx))
|
||||||
})
|
})
|
||||||
@@ -150,3 +152,10 @@ func GetUserEmail(ctx context.Context) string {
|
|||||||
}
|
}
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func GetUserTier(ctx context.Context) string {
|
||||||
|
if v, ok := ctx.Value(UserTierKey).(string); ok {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
return "free"
|
||||||
|
}
|
||||||
|
|||||||
60
backend/internal/middleware/auth_test.go
Normal file
60
backend/internal/middleware/auth_test.go
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
package middleware
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestGetUserID_Empty(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
if id := GetUserID(ctx); id != "" {
|
||||||
|
t.Errorf("expected empty user ID, got %q", id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetUserID_Set(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
ctx := context.WithValue(context.Background(), UserIDKey, "abc-123")
|
||||||
|
if id := GetUserID(ctx); id != "abc-123" {
|
||||||
|
t.Errorf("expected abc-123, got %q", id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetUserEmail_Empty(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
if email := GetUserEmail(ctx); email != "" {
|
||||||
|
t.Errorf("expected empty email, got %q", email)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetUserEmail_Set(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
ctx := context.WithValue(context.Background(), UserEmailKey, "test@example.com")
|
||||||
|
if email := GetUserEmail(ctx); email != "test@example.com" {
|
||||||
|
t.Errorf("expected test@example.com, got %q", email)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetUserTier_Default(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
if tier := GetUserTier(ctx); tier != "free" {
|
||||||
|
t.Errorf("expected default tier 'free', got %q", tier)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetUserTier_Set(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
ctx := context.WithValue(context.Background(), UserTierKey, "pro")
|
||||||
|
if tier := GetUserTier(ctx); tier != "pro" {
|
||||||
|
t.Errorf("expected 'pro', got %q", tier)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -55,12 +55,14 @@ func (m *AddressModel) ListByUser(ctx context.Context, userID string) ([]domain.
|
|||||||
return addresses, rows.Err()
|
return addresses, rows.Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
// ListAll returns all addresses system-wide (used by the poller).
|
// ListAll returns addresses for users with an active subscription (used by the poller).
|
||||||
func (m *AddressModel) ListAll(ctx context.Context) ([]domain.Address, error) {
|
func (m *AddressModel) ListAll(ctx context.Context) ([]domain.Address, error) {
|
||||||
rows, err := m.pool.Query(ctx,
|
rows, err := m.pool.Query(ctx,
|
||||||
`SELECT id, user_id, address, label, created_at
|
`SELECT a.id, a.user_id, a.address, a.label, a.created_at
|
||||||
FROM addresses
|
FROM addresses a
|
||||||
ORDER BY created_at DESC`,
|
JOIN users u ON u.id = a.user_id
|
||||||
|
WHERE u.subscription_status IN ('active', 'trialing')
|
||||||
|
ORDER BY a.created_at DESC`,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -125,6 +127,15 @@ func (m *AddressModel) UpdateLabel(ctx context.Context, id int, userID string, l
|
|||||||
return &a, nil
|
return &a, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m *AddressModel) CountByUser(ctx context.Context, userID string) (int, error) {
|
||||||
|
var count int
|
||||||
|
err := m.pool.QueryRow(ctx,
|
||||||
|
`SELECT COUNT(*) FROM addresses WHERE user_id = $1`,
|
||||||
|
userID,
|
||||||
|
).Scan(&count)
|
||||||
|
return count, err
|
||||||
|
}
|
||||||
|
|
||||||
func (m *AddressModel) Remove(ctx context.Context, id int, userID string) (bool, error) {
|
func (m *AddressModel) Remove(ctx context.Context, id int, userID string) (bool, error) {
|
||||||
tag, err := m.pool.Exec(ctx,
|
tag, err := m.pool.Exec(ctx,
|
||||||
`DELETE FROM addresses WHERE id = $1 AND user_id = $2`,
|
`DELETE FROM addresses WHERE id = $1 AND user_id = $2`,
|
||||||
|
|||||||
@@ -17,14 +17,14 @@ func NewAlertRuleModel(pool *pgxpool.Pool) *AlertRuleModel {
|
|||||||
return &AlertRuleModel{pool: pool}
|
return &AlertRuleModel{pool: pool}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *AlertRuleModel) Create(ctx context.Context, addressID int, alertType domain.AlertType, threshold *float64) (*domain.AlertRule, error) {
|
func (m *AlertRuleModel) Create(ctx context.Context, addressID int, alertType domain.AlertType, threshold, minimum, maximum *float64) (*domain.AlertRule, error) {
|
||||||
var r domain.AlertRule
|
var r domain.AlertRule
|
||||||
err := m.pool.QueryRow(ctx,
|
err := m.pool.QueryRow(ctx,
|
||||||
`INSERT INTO alert_rules (address_id, type, threshold, enabled)
|
`INSERT INTO alert_rules (address_id, type, threshold, minimum, maximum, enabled)
|
||||||
VALUES ($1, $2, $3, TRUE)
|
VALUES ($1, $2, $3, $4, $5, TRUE)
|
||||||
RETURNING id, address_id, type, threshold, enabled, created_at`,
|
RETURNING id, address_id, type, threshold, minimum, maximum, enabled, created_at`,
|
||||||
addressID, alertType.String(), threshold,
|
addressID, alertType.String(), threshold, minimum, maximum,
|
||||||
).Scan(&r.ID, &r.AddressID, &r.Type, &r.Threshold, &r.Enabled, &r.CreatedAt)
|
).Scan(&r.ID, &r.AddressID, &r.Type, &r.Threshold, &r.Minimum, &r.Maximum, &r.Enabled, &r.CreatedAt)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -33,7 +33,7 @@ func (m *AlertRuleModel) Create(ctx context.Context, addressID int, alertType do
|
|||||||
|
|
||||||
func (m *AlertRuleModel) ListByAddress(ctx context.Context, addressID int) ([]domain.AlertRule, error) {
|
func (m *AlertRuleModel) ListByAddress(ctx context.Context, addressID int) ([]domain.AlertRule, error) {
|
||||||
rows, err := m.pool.Query(ctx,
|
rows, err := m.pool.Query(ctx,
|
||||||
`SELECT id, address_id, type, threshold, enabled, created_at
|
`SELECT id, address_id, type, threshold, minimum, maximum, enabled, created_at
|
||||||
FROM alert_rules
|
FROM alert_rules
|
||||||
WHERE address_id = $1
|
WHERE address_id = $1
|
||||||
ORDER BY created_at DESC`,
|
ORDER BY created_at DESC`,
|
||||||
@@ -47,7 +47,7 @@ func (m *AlertRuleModel) ListByAddress(ctx context.Context, addressID int) ([]do
|
|||||||
var rules []domain.AlertRule
|
var rules []domain.AlertRule
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var r domain.AlertRule
|
var r domain.AlertRule
|
||||||
if err := rows.Scan(&r.ID, &r.AddressID, &r.Type, &r.Threshold, &r.Enabled, &r.CreatedAt); err != nil {
|
if err := rows.Scan(&r.ID, &r.AddressID, &r.Type, &r.Threshold, &r.Minimum, &r.Maximum, &r.Enabled, &r.CreatedAt); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
rules = append(rules, r)
|
rules = append(rules, r)
|
||||||
@@ -61,19 +61,19 @@ func (m *AlertRuleModel) FindByID(ctx context.Context, id int, userID *string) (
|
|||||||
|
|
||||||
if userID != nil {
|
if userID != nil {
|
||||||
err = m.pool.QueryRow(ctx,
|
err = m.pool.QueryRow(ctx,
|
||||||
`SELECT ar.id, ar.address_id, ar.type, ar.threshold, ar.enabled, ar.created_at
|
`SELECT ar.id, ar.address_id, ar.type, ar.threshold, ar.minimum, ar.maximum, ar.enabled, ar.created_at
|
||||||
FROM alert_rules ar
|
FROM alert_rules ar
|
||||||
JOIN addresses a ON a.id = ar.address_id
|
JOIN addresses a ON a.id = ar.address_id
|
||||||
WHERE ar.id = $1 AND a.user_id = $2`,
|
WHERE ar.id = $1 AND a.user_id = $2`,
|
||||||
id, *userID,
|
id, *userID,
|
||||||
).Scan(&r.ID, &r.AddressID, &r.Type, &r.Threshold, &r.Enabled, &r.CreatedAt)
|
).Scan(&r.ID, &r.AddressID, &r.Type, &r.Threshold, &r.Minimum, &r.Maximum, &r.Enabled, &r.CreatedAt)
|
||||||
} else {
|
} else {
|
||||||
err = m.pool.QueryRow(ctx,
|
err = m.pool.QueryRow(ctx,
|
||||||
`SELECT id, address_id, type, threshold, enabled, created_at
|
`SELECT id, address_id, type, threshold, minimum, maximum, enabled, created_at
|
||||||
FROM alert_rules
|
FROM alert_rules
|
||||||
WHERE id = $1`,
|
WHERE id = $1`,
|
||||||
id,
|
id,
|
||||||
).Scan(&r.ID, &r.AddressID, &r.Type, &r.Threshold, &r.Enabled, &r.CreatedAt)
|
).Scan(&r.ID, &r.AddressID, &r.Type, &r.Threshold, &r.Minimum, &r.Maximum, &r.Enabled, &r.CreatedAt)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -91,9 +91,9 @@ func (m *AlertRuleModel) UpdateEnabled(ctx context.Context, id int, enabled bool
|
|||||||
`UPDATE alert_rules
|
`UPDATE alert_rules
|
||||||
SET enabled = $2
|
SET enabled = $2
|
||||||
WHERE id = $1
|
WHERE id = $1
|
||||||
RETURNING id, address_id, type, threshold, enabled, created_at`,
|
RETURNING id, address_id, type, threshold, minimum, maximum, enabled, created_at`,
|
||||||
id, enabled,
|
id, enabled,
|
||||||
).Scan(&r.ID, &r.AddressID, &r.Type, &r.Threshold, &r.Enabled, &r.CreatedAt)
|
).Scan(&r.ID, &r.AddressID, &r.Type, &r.Threshold, &r.Minimum, &r.Maximum, &r.Enabled, &r.CreatedAt)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, pgx.ErrNoRows) {
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
@@ -103,6 +103,33 @@ func (m *AlertRuleModel) UpdateEnabled(ctx context.Context, id int, enabled bool
|
|||||||
return &r, nil
|
return &r, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m *AlertRuleModel) UpdateThresholds(ctx context.Context, id int, minimum, maximum *float64) (*domain.AlertRule, error) {
|
||||||
|
var r domain.AlertRule
|
||||||
|
err := m.pool.QueryRow(ctx,
|
||||||
|
`UPDATE alert_rules
|
||||||
|
SET minimum = $2, maximum = $3
|
||||||
|
WHERE id = $1
|
||||||
|
RETURNING id, address_id, type, threshold, minimum, maximum, enabled, created_at`,
|
||||||
|
id, minimum, maximum,
|
||||||
|
).Scan(&r.ID, &r.AddressID, &r.Type, &r.Threshold, &r.Minimum, &r.Maximum, &r.Enabled, &r.CreatedAt)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &r, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *AlertRuleModel) CountDistinctTypesByAddress(ctx context.Context, addressID int) (int, error) {
|
||||||
|
var count int
|
||||||
|
err := m.pool.QueryRow(ctx,
|
||||||
|
`SELECT COUNT(DISTINCT type) FROM alert_rules WHERE address_id = $1`,
|
||||||
|
addressID,
|
||||||
|
).Scan(&count)
|
||||||
|
return count, err
|
||||||
|
}
|
||||||
|
|
||||||
func (m *AlertRuleModel) Remove(ctx context.Context, id int) (bool, error) {
|
func (m *AlertRuleModel) Remove(ctx context.Context, id int) (bool, error) {
|
||||||
tag, err := m.pool.Exec(ctx,
|
tag, err := m.pool.Exec(ctx,
|
||||||
`DELETE FROM alert_rules WHERE id = $1`,
|
`DELETE FROM alert_rules WHERE id = $1`,
|
||||||
@@ -113,3 +140,33 @@ func (m *AlertRuleModel) Remove(ctx context.Context, id int) (bool, error) {
|
|||||||
}
|
}
|
||||||
return tag.RowsAffected() > 0, nil
|
return tag.RowsAffected() > 0, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DisableAllForUser sets enabled = false on every alert rule owned by addresses of userID.
|
||||||
|
func (m *AlertRuleModel) DisableAllForUser(ctx context.Context, userID string) (int64, error) {
|
||||||
|
tag, err := m.pool.Exec(ctx,
|
||||||
|
`UPDATE alert_rules ar
|
||||||
|
SET enabled = FALSE
|
||||||
|
FROM addresses a
|
||||||
|
WHERE ar.address_id = a.id AND a.user_id = $1`,
|
||||||
|
userID,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return tag.RowsAffected(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// EnableAllForUser sets enabled = true on every alert rule owned by addresses of userID.
|
||||||
|
func (m *AlertRuleModel) EnableAllForUser(ctx context.Context, userID string) (int64, error) {
|
||||||
|
tag, err := m.pool.Exec(ctx,
|
||||||
|
`UPDATE alert_rules ar
|
||||||
|
SET enabled = TRUE
|
||||||
|
FROM addresses a
|
||||||
|
WHERE ar.address_id = a.id AND a.user_id = $1`,
|
||||||
|
userID,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return tag.RowsAffected(), nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -77,10 +77,12 @@ func (m *NotificationConfigModel) Remove(ctx context.Context, userID string) (bo
|
|||||||
|
|
||||||
func (m *NotificationConfigModel) ListEnabled(ctx context.Context) ([]domain.NotificationConfig, error) {
|
func (m *NotificationConfigModel) ListEnabled(ctx context.Context) ([]domain.NotificationConfig, error) {
|
||||||
rows, err := m.pool.Query(ctx,
|
rows, err := m.pool.Query(ctx,
|
||||||
`SELECT user_id, discord_webhook_url, telegram_chat_id, telegram_bot_token,
|
`SELECT nc.user_id, nc.discord_webhook_url, nc.telegram_chat_id, nc.telegram_bot_token,
|
||||||
email, slack_webhook_url
|
nc.email, nc.slack_webhook_url
|
||||||
FROM user_notification_configs
|
FROM user_notification_configs nc
|
||||||
WHERE notification_enabled = TRUE`,
|
JOIN users u ON u.id = nc.user_id
|
||||||
|
WHERE nc.notification_enabled = TRUE
|
||||||
|
AND u.subscription_status IN ('active', 'trialing')`,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
|
|
||||||
"github.com/jackc/pgx/v5"
|
"github.com/jackc/pgx/v5"
|
||||||
|
"github.com/jackc/pgx/v5/pgconn"
|
||||||
"github.com/jackc/pgx/v5/pgxpool"
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
"github.com/kjannette/koin-ping/backend/internal/domain"
|
"github.com/kjannette/koin-ping/backend/internal/domain"
|
||||||
)
|
)
|
||||||
@@ -19,14 +20,14 @@ func NewUserModel(pool *pgxpool.Pool) *UserModel {
|
|||||||
|
|
||||||
const userColumns = `id, firebase_uid, email, display_name,
|
const userColumns = `id, firebase_uid, email, display_name,
|
||||||
stripe_customer_id, stripe_subscription_id, subscription_status,
|
stripe_customer_id, stripe_subscription_id, subscription_status,
|
||||||
subscription_created_at, created_at, updated_at`
|
subscription_tier, subscription_created_at, created_at, updated_at`
|
||||||
|
|
||||||
func scanUser(row pgx.Row) (*domain.User, error) {
|
func scanUser(row pgx.Row) (*domain.User, error) {
|
||||||
var u domain.User
|
var u domain.User
|
||||||
err := row.Scan(
|
err := row.Scan(
|
||||||
&u.ID, &u.FirebaseUID, &u.Email, &u.DisplayName,
|
&u.ID, &u.FirebaseUID, &u.Email, &u.DisplayName,
|
||||||
&u.StripeCustomerID, &u.StripeSubscriptionID, &u.SubscriptionStatus,
|
&u.StripeCustomerID, &u.StripeSubscriptionID, &u.SubscriptionStatus,
|
||||||
&u.SubscriptionCreatedAt, &u.CreatedAt, &u.UpdatedAt,
|
&u.SubscriptionTier, &u.SubscriptionCreatedAt, &u.CreatedAt, &u.UpdatedAt,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, pgx.ErrNoRows) {
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
@@ -37,10 +38,55 @@ func scanUser(row pgx.Row) (*domain.User, error) {
|
|||||||
return &u, nil
|
return &u, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m *UserModel) getByFirebaseUID(ctx context.Context, firebaseUID string) (*domain.User, error) {
|
||||||
|
row := m.pool.QueryRow(ctx,
|
||||||
|
`SELECT `+userColumns+` FROM users WHERE firebase_uid = $1`,
|
||||||
|
firebaseUID,
|
||||||
|
)
|
||||||
|
return scanUser(row)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *UserModel) getByEmail(ctx context.Context, email string) (*domain.User, error) {
|
||||||
|
row := m.pool.QueryRow(ctx,
|
||||||
|
`SELECT `+userColumns+` FROM users WHERE email = $1`,
|
||||||
|
email,
|
||||||
|
)
|
||||||
|
return scanUser(row)
|
||||||
|
}
|
||||||
|
|
||||||
|
func isUniqueViolation(err error) bool {
|
||||||
|
var pgErr *pgconn.PgError
|
||||||
|
if errors.As(err, &pgErr) {
|
||||||
|
return pgErr.Code == "23505"
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
// FindOrCreateByFirebaseUID returns the local user for a Firebase UID,
|
// FindOrCreateByFirebaseUID returns the local user for a Firebase UID,
|
||||||
// creating one if it doesn't exist yet. On conflict (returning user) the
|
// creating one if it doesn't exist yet.
|
||||||
// updated_at timestamp is refreshed.
|
//
|
||||||
|
// For onboarding races or legacy duplicate-identity states, this method
|
||||||
|
// gracefully falls back to an existing row by email instead of failing
|
||||||
|
// with a unique-email violation.
|
||||||
func (m *UserModel) FindOrCreateByFirebaseUID(ctx context.Context, firebaseUID, email string) (*domain.User, error) {
|
func (m *UserModel) FindOrCreateByFirebaseUID(ctx context.Context, firebaseUID, email string) (*domain.User, error) {
|
||||||
|
existingByUID, err := m.getByFirebaseUID(ctx, firebaseUID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if existingByUID != nil {
|
||||||
|
return existingByUID, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if email != "" {
|
||||||
|
existingByEmail, err := m.getByEmail(ctx, email)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if existingByEmail != nil {
|
||||||
|
return existingByEmail, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
row := m.pool.QueryRow(ctx,
|
row := m.pool.QueryRow(ctx,
|
||||||
`INSERT INTO users (firebase_uid, email)
|
`INSERT INTO users (firebase_uid, email)
|
||||||
VALUES ($1, $2)
|
VALUES ($1, $2)
|
||||||
@@ -48,7 +94,33 @@ func (m *UserModel) FindOrCreateByFirebaseUID(ctx context.Context, firebaseUID,
|
|||||||
RETURNING `+userColumns,
|
RETURNING `+userColumns,
|
||||||
firebaseUID, email,
|
firebaseUID, email,
|
||||||
)
|
)
|
||||||
return scanUser(row)
|
user, err := scanUser(row)
|
||||||
|
if err == nil {
|
||||||
|
return user, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// If a concurrent request inserted by email or firebase_uid first,
|
||||||
|
// read the existing record and continue without surfacing a 500.
|
||||||
|
if isUniqueViolation(err) {
|
||||||
|
existingByUID, readErr := m.getByFirebaseUID(ctx, firebaseUID)
|
||||||
|
if readErr != nil {
|
||||||
|
return nil, readErr
|
||||||
|
}
|
||||||
|
if existingByUID != nil {
|
||||||
|
return existingByUID, nil
|
||||||
|
}
|
||||||
|
if email != "" {
|
||||||
|
existingByEmail, readErr := m.getByEmail(ctx, email)
|
||||||
|
if readErr != nil {
|
||||||
|
return nil, readErr
|
||||||
|
}
|
||||||
|
if existingByEmail != nil {
|
||||||
|
return existingByEmail, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *UserModel) GetByID(ctx context.Context, id string) (*domain.User, error) {
|
func (m *UserModel) GetByID(ctx context.Context, id string) (*domain.User, error) {
|
||||||
@@ -58,6 +130,46 @@ func (m *UserModel) GetByID(ctx context.Context, id string) (*domain.User, error
|
|||||||
return scanUser(row)
|
return scanUser(row)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetByStripeCustomerID loads a user by their Stripe Customer ID if set.
|
||||||
|
func (m *UserModel) GetByStripeCustomerID(ctx context.Context, stripeCustomerID string) (*domain.User, error) {
|
||||||
|
row := m.pool.QueryRow(ctx,
|
||||||
|
`SELECT `+userColumns+` FROM users WHERE stripe_customer_id = $1`,
|
||||||
|
stripeCustomerID,
|
||||||
|
)
|
||||||
|
return scanUser(row)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListPaidUsersWithoutActiveSubscription finds paid-tier rows whose Stripe
|
||||||
|
// subscription is not active or trialing. Used by the periodic billing sweep.
|
||||||
|
func (m *UserModel) ListPaidUsersWithoutActiveSubscription(ctx context.Context) ([]domain.User, error) {
|
||||||
|
rows, err := m.pool.Query(ctx,
|
||||||
|
`SELECT `+userColumns+` FROM users
|
||||||
|
WHERE subscription_tier IN ('premium', 'pro')
|
||||||
|
AND subscription_status NOT IN ('active', 'trialing')
|
||||||
|
AND COALESCE(trim(firebase_uid), '') <> ''`,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
out := []domain.User{}
|
||||||
|
for rows.Next() {
|
||||||
|
var u domain.User
|
||||||
|
rowErr := rows.Scan(
|
||||||
|
&u.ID, &u.FirebaseUID, &u.Email, &u.DisplayName,
|
||||||
|
&u.StripeCustomerID, &u.StripeSubscriptionID, &u.SubscriptionStatus,
|
||||||
|
&u.SubscriptionTier, &u.SubscriptionCreatedAt, &u.CreatedAt, &u.UpdatedAt,
|
||||||
|
)
|
||||||
|
if rowErr != nil {
|
||||||
|
return nil, rowErr
|
||||||
|
}
|
||||||
|
out = append(out, u)
|
||||||
|
}
|
||||||
|
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
func (m *UserModel) UpdateStripeCustomer(ctx context.Context, userID, stripeCustomerID string) error {
|
func (m *UserModel) UpdateStripeCustomer(ctx context.Context, userID, stripeCustomerID string) error {
|
||||||
_, err := m.pool.Exec(ctx,
|
_, err := m.pool.Exec(ctx,
|
||||||
`UPDATE users SET stripe_customer_id = $2, updated_at = NOW() WHERE id = $1`,
|
`UPDATE users SET stripe_customer_id = $2, updated_at = NOW() WHERE id = $1`,
|
||||||
@@ -66,15 +178,37 @@ func (m *UserModel) UpdateStripeCustomer(ctx context.Context, userID, stripeCust
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *UserModel) ActivateSubscription(ctx context.Context, stripeCustomerID, subscriptionID, status string) error {
|
func (m *UserModel) ActivateSubscription(ctx context.Context, stripeCustomerID, subscriptionID, status string, tier domain.SubscriptionTier) error {
|
||||||
_, err := m.pool.Exec(ctx,
|
_, err := m.pool.Exec(ctx,
|
||||||
`UPDATE users
|
`UPDATE users
|
||||||
SET stripe_subscription_id = $2,
|
SET stripe_subscription_id = $2,
|
||||||
subscription_status = $3,
|
subscription_status = $3,
|
||||||
|
subscription_tier = $4,
|
||||||
subscription_created_at = COALESCE(subscription_created_at, NOW()),
|
subscription_created_at = COALESCE(subscription_created_at, NOW()),
|
||||||
updated_at = NOW()
|
updated_at = NOW()
|
||||||
WHERE stripe_customer_id = $1`,
|
WHERE stripe_customer_id = $1`,
|
||||||
stripeCustomerID, subscriptionID, status,
|
stripeCustomerID, subscriptionID, status, string(tier),
|
||||||
|
)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *UserModel) UpdateSubscriptionTier(ctx context.Context, userID string, tier domain.SubscriptionTier) error {
|
||||||
|
_, err := m.pool.Exec(ctx,
|
||||||
|
`UPDATE users SET subscription_tier = $2, updated_at = NOW() WHERE id = $1`,
|
||||||
|
userID, string(tier),
|
||||||
|
)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *UserModel) ActivateFreeTier(ctx context.Context, userID string) error {
|
||||||
|
_, err := m.pool.Exec(ctx,
|
||||||
|
`UPDATE users
|
||||||
|
SET subscription_status = 'active',
|
||||||
|
subscription_tier = 'free',
|
||||||
|
subscription_created_at = COALESCE(subscription_created_at, NOW()),
|
||||||
|
updated_at = NOW()
|
||||||
|
WHERE id = $1`,
|
||||||
|
userID,
|
||||||
)
|
)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"html"
|
||||||
|
"io"
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"time"
|
"time"
|
||||||
@@ -156,3 +158,57 @@ func alertTypeLabel(alertType string) string {
|
|||||||
return "Alert"
|
return "Alert"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type resendSupportPayload struct {
|
||||||
|
From string `json:"from"`
|
||||||
|
To string `json:"to"`
|
||||||
|
Subject string `json:"subject"`
|
||||||
|
Text string `json:"text"`
|
||||||
|
HTML string `json:"html"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SendSupportEmail delivers a plain-text support request via Resend (HTML copy is escaped).
|
||||||
|
func SendSupportEmail(apiKey, fromAddress, toAddress, subject, plainBody string) error {
|
||||||
|
if apiKey == "" {
|
||||||
|
return fmt.Errorf("RESEND_API_KEY not set") //nolint:err113
|
||||||
|
}
|
||||||
|
|
||||||
|
escaped := html.EscapeString(plainBody)
|
||||||
|
htmlBody := `<pre style="font-family:ui-sans-serif,system-ui,sans-serif;white-space:pre-wrap;">` +
|
||||||
|
escaped + `</pre>`
|
||||||
|
|
||||||
|
payload := resendSupportPayload{
|
||||||
|
From: fromAddress,
|
||||||
|
To: toAddress,
|
||||||
|
Subject: subject,
|
||||||
|
Text: plainBody,
|
||||||
|
HTML: htmlBody,
|
||||||
|
}
|
||||||
|
|
||||||
|
body, err := json.Marshal(payload)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("marshal support email payload: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := http.NewRequest(http.MethodPost, "https://api.resend.com/emails", bytes.NewReader(body))
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("create support email request: %w", err)
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.Header.Set("Authorization", "Bearer "+apiKey)
|
||||||
|
|
||||||
|
resp, err := emailHTTPClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Failed to send support email: %v", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||||
|
snippet, _ := io.ReadAll(io.LimitReader(resp.Body, 2048))
|
||||||
|
log.Printf("Resend support email failed: HTTP %d body: %s", resp.StatusCode, string(snippet))
|
||||||
|
return fmt.Errorf("resend API failed: HTTP %d: %s", resp.StatusCode, string(snippet)) //nolint:err113
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -28,9 +28,8 @@ type EvaluatorService struct {
|
|||||||
alertRules *models.AlertRuleModel
|
alertRules *models.AlertRuleModel
|
||||||
alertEvents *models.AlertEventModel
|
alertEvents *models.AlertEventModel
|
||||||
addresses *models.AddressModel
|
addresses *models.AddressModel
|
||||||
|
users *models.UserModel
|
||||||
notifConfigs *models.NotificationConfigModel
|
notifConfigs *models.NotificationConfigModel
|
||||||
resendAPIKey string
|
|
||||||
emailFrom string
|
|
||||||
notifSem *semaphore.Weighted
|
notifSem *semaphore.Weighted
|
||||||
notifWg sync.WaitGroup
|
notifWg sync.WaitGroup
|
||||||
}
|
}
|
||||||
@@ -40,18 +39,16 @@ func NewEvaluatorService(
|
|||||||
alertRules *models.AlertRuleModel,
|
alertRules *models.AlertRuleModel,
|
||||||
alertEvents *models.AlertEventModel,
|
alertEvents *models.AlertEventModel,
|
||||||
addresses *models.AddressModel,
|
addresses *models.AddressModel,
|
||||||
|
users *models.UserModel,
|
||||||
notifConfigs *models.NotificationConfigModel,
|
notifConfigs *models.NotificationConfigModel,
|
||||||
resendAPIKey string,
|
|
||||||
emailFrom string,
|
|
||||||
) *EvaluatorService {
|
) *EvaluatorService {
|
||||||
return &EvaluatorService{
|
return &EvaluatorService{
|
||||||
eth: eth,
|
eth: eth,
|
||||||
alertRules: alertRules,
|
alertRules: alertRules,
|
||||||
alertEvents: alertEvents,
|
alertEvents: alertEvents,
|
||||||
addresses: addresses,
|
addresses: addresses,
|
||||||
|
users: users,
|
||||||
notifConfigs: notifConfigs,
|
notifConfigs: notifConfigs,
|
||||||
resendAPIKey: resendAPIKey,
|
|
||||||
emailFrom: emailFrom,
|
|
||||||
notifSem: semaphore.NewWeighted(maxConcurrentNotifications),
|
notifSem: semaphore.NewWeighted(maxConcurrentNotifications),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -104,9 +101,9 @@ func (s *EvaluatorService) evaluateObservation(ctx context.Context, obs domain.O
|
|||||||
func (s *EvaluatorService) ruleMatches(ctx context.Context, rule domain.AlertRule, obs domain.ObservedTx) (bool, error) {
|
func (s *EvaluatorService) ruleMatches(ctx context.Context, rule domain.AlertRule, obs domain.ObservedTx) (bool, error) {
|
||||||
switch rule.Type {
|
switch rule.Type {
|
||||||
case domain.AlertIncomingTx:
|
case domain.AlertIncomingTx:
|
||||||
return obs.Direction == domain.DirectionIncoming, nil
|
return s.matchesDirectionalTx(rule, obs, domain.DirectionIncoming)
|
||||||
case domain.AlertOutgoingTx:
|
case domain.AlertOutgoingTx:
|
||||||
return obs.Direction == domain.DirectionOutgoing, nil
|
return s.matchesDirectionalTx(rule, obs, domain.DirectionOutgoing)
|
||||||
case domain.AlertLargeTransfer:
|
case domain.AlertLargeTransfer:
|
||||||
return s.matchesLargeTransfer(rule, obs)
|
return s.matchesLargeTransfer(rule, obs)
|
||||||
case domain.AlertBalanceBelow:
|
case domain.AlertBalanceBelow:
|
||||||
@@ -117,6 +114,42 @@ func (s *EvaluatorService) ruleMatches(ctx context.Context, rule domain.AlertRul
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *EvaluatorService) matchesDirectionalTx(rule domain.AlertRule, obs domain.ObservedTx, expected domain.Direction) (bool, error) {
|
||||||
|
if obs.Direction != expected {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if rule.Minimum != nil {
|
||||||
|
minWei, err := wei.FromEth(*rule.Minimum)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
aboveMin, err := wei.GreaterThanOrEqual(obs.Value, minWei)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
if !aboveMin {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if rule.Maximum != nil {
|
||||||
|
maxWei, err := wei.FromEth(*rule.Maximum)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
belowMax, err := wei.LessThanOrEqual(obs.Value, maxWei)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
if !belowMax {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (s *EvaluatorService) matchesLargeTransfer(rule domain.AlertRule, obs domain.ObservedTx) (bool, error) {
|
func (s *EvaluatorService) matchesLargeTransfer(rule domain.AlertRule, obs domain.ObservedTx) (bool, error) {
|
||||||
if rule.Threshold == nil {
|
if rule.Threshold == nil {
|
||||||
return false, nil
|
return false, nil
|
||||||
@@ -218,14 +251,16 @@ func (s *EvaluatorService) WaitForNotifications() {
|
|||||||
s.notifWg.Wait()
|
s.notifWg.Wait()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *EvaluatorService) buildNotifiers(cfg *domain.NotificationConfig) []notifications.Notifier {
|
func (s *EvaluatorService) buildNotifiers(cfg *domain.NotificationConfig, limits domain.TierLimits) []notifications.Notifier {
|
||||||
var notifiers []notifications.Notifier
|
var notifiers []notifications.Notifier
|
||||||
|
|
||||||
if cfg.DiscordWebhookURL != nil && *cfg.DiscordWebhookURL != "" {
|
if limits.ChannelAllowed("discord") &&
|
||||||
|
cfg.DiscordWebhookURL != nil && *cfg.DiscordWebhookURL != "" {
|
||||||
notifiers = append(notifiers, ¬ifications.DiscordNotifier{WebhookURL: *cfg.DiscordWebhookURL})
|
notifiers = append(notifiers, ¬ifications.DiscordNotifier{WebhookURL: *cfg.DiscordWebhookURL})
|
||||||
}
|
}
|
||||||
|
|
||||||
if cfg.TelegramBotToken != nil && *cfg.TelegramBotToken != "" &&
|
if limits.ChannelAllowed("telegram") &&
|
||||||
|
cfg.TelegramBotToken != nil && *cfg.TelegramBotToken != "" &&
|
||||||
cfg.TelegramChatID != nil && *cfg.TelegramChatID != "" {
|
cfg.TelegramChatID != nil && *cfg.TelegramChatID != "" {
|
||||||
notifiers = append(notifiers, ¬ifications.TelegramNotifier{
|
notifiers = append(notifiers, ¬ifications.TelegramNotifier{
|
||||||
BotToken: *cfg.TelegramBotToken,
|
BotToken: *cfg.TelegramBotToken,
|
||||||
@@ -233,17 +268,12 @@ func (s *EvaluatorService) buildNotifiers(cfg *domain.NotificationConfig) []noti
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
if cfg.SlackWebhookURL != nil && *cfg.SlackWebhookURL != "" {
|
if limits.ChannelAllowed("slack") &&
|
||||||
|
cfg.SlackWebhookURL != nil && *cfg.SlackWebhookURL != "" {
|
||||||
notifiers = append(notifiers, ¬ifications.SlackNotifier{WebhookURL: *cfg.SlackWebhookURL})
|
notifiers = append(notifiers, ¬ifications.SlackNotifier{WebhookURL: *cfg.SlackWebhookURL})
|
||||||
}
|
}
|
||||||
|
|
||||||
if cfg.Email != nil && *cfg.Email != "" {
|
// Email is delivered exclusively via the daily digest service, never real-time.
|
||||||
notifiers = append(notifiers, ¬ifications.EmailNotifier{
|
|
||||||
APIKey: s.resendAPIKey,
|
|
||||||
From: s.emailFrom,
|
|
||||||
To: *cfg.Email,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return notifiers
|
return notifiers
|
||||||
}
|
}
|
||||||
@@ -289,6 +319,13 @@ func (s *EvaluatorService) sendNotification(ctx context.Context, userID, message
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
user, err := s.users.GetByID(ctx, userID)
|
||||||
|
if err != nil || user == nil {
|
||||||
|
log.Printf("Failed to load user %s for tier check in notification: %v", userID, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
limits := domain.GetTierLimits(user.SubscriptionTier)
|
||||||
|
|
||||||
meta := notifications.AlertMetadata{
|
meta := notifications.AlertMetadata{
|
||||||
TxHash: obs.Hash,
|
TxHash: obs.Hash,
|
||||||
AddressLabel: addressLabel,
|
AddressLabel: addressLabel,
|
||||||
@@ -296,7 +333,7 @@ func (s *EvaluatorService) sendNotification(ctx context.Context, userID, message
|
|||||||
Address: address,
|
Address: address,
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, n := range s.buildNotifiers(notifConfig) {
|
for _, n := range s.buildNotifiers(notifConfig, limits) {
|
||||||
if err := sendWithRetry(ctx, n, message, meta); err != nil {
|
if err := sendWithRetry(ctx, n, message, meta); err != nil {
|
||||||
log.Printf("Notification channel failed for user %s after retries: %v", userID, err)
|
log.Printf("Notification channel failed for user %s after retries: %v", userID, err)
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -90,6 +90,14 @@ func LessThan(weiA, weiB string) (bool, error) {
|
|||||||
return cmp < 0, nil
|
return cmp < 0, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func LessThanOrEqual(weiA, weiB string) (bool, error) {
|
||||||
|
cmp, err := Compare(weiA, weiB)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
return cmp <= 0, nil
|
||||||
|
}
|
||||||
|
|
||||||
// FormatAsEth formats a Wei string as "X.XXXX ETH".
|
// FormatAsEth formats a Wei string as "X.XXXX ETH".
|
||||||
func FormatAsEth(weiString string, decimals int) (string, error) {
|
func FormatAsEth(weiString string, decimals int) (string, error) {
|
||||||
eth, err := ToEth(weiString)
|
eth, err := ToEth(weiString)
|
||||||
|
|||||||
264
frontend/package-lock.json
generated
264
frontend/package-lock.json
generated
@@ -12,7 +12,7 @@
|
|||||||
"firebase": "^12.7.0",
|
"firebase": "^12.7.0",
|
||||||
"react": "^19.2.3",
|
"react": "^19.2.3",
|
||||||
"react-dom": "^19.2.3",
|
"react-dom": "^19.2.3",
|
||||||
"react-router-dom": "^7.11.0"
|
"react-router-dom": "^7.12.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/react": "^19.2.7",
|
"@types/react": "^19.2.7",
|
||||||
@@ -20,7 +20,7 @@
|
|||||||
"@vitejs/plugin-react": "^5.1.2",
|
"@vitejs/plugin-react": "^5.1.2",
|
||||||
"prettier": "^3.8.1",
|
"prettier": "^3.8.1",
|
||||||
"typescript": "^5.9.3",
|
"typescript": "^5.9.3",
|
||||||
"vite": "^7.3.0"
|
"vite": "^7.3.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@babel/code-frame": {
|
"node_modules/@babel/code-frame": {
|
||||||
@@ -1513,9 +1513,9 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-android-arm-eabi": {
|
"node_modules/@rollup/rollup-android-arm-eabi": {
|
||||||
"version": "4.54.0",
|
"version": "4.59.0",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.54.0.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz",
|
||||||
"integrity": "sha512-OywsdRHrFvCdvsewAInDKCNyR3laPA2mc9bRYJ6LBp5IyvF3fvXbbNR0bSzHlZVFtn6E0xw2oZlyjg4rKCVcng==",
|
"integrity": "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
@@ -1527,9 +1527,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-android-arm64": {
|
"node_modules/@rollup/rollup-android-arm64": {
|
||||||
"version": "4.54.0",
|
"version": "4.59.0",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.54.0.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.59.0.tgz",
|
||||||
"integrity": "sha512-Skx39Uv+u7H224Af+bDgNinitlmHyQX1K/atIA32JP3JQw6hVODX5tkbi2zof/E69M1qH2UoN3Xdxgs90mmNYw==",
|
"integrity": "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -1541,9 +1541,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-darwin-arm64": {
|
"node_modules/@rollup/rollup-darwin-arm64": {
|
||||||
"version": "4.54.0",
|
"version": "4.59.0",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.54.0.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.59.0.tgz",
|
||||||
"integrity": "sha512-k43D4qta/+6Fq+nCDhhv9yP2HdeKeP56QrUUTW7E6PhZP1US6NDqpJj4MY0jBHlJivVJD5P8NxrjuobZBJTCRw==",
|
"integrity": "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -1555,9 +1555,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-darwin-x64": {
|
"node_modules/@rollup/rollup-darwin-x64": {
|
||||||
"version": "4.54.0",
|
"version": "4.59.0",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.54.0.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.59.0.tgz",
|
||||||
"integrity": "sha512-cOo7biqwkpawslEfox5Vs8/qj83M/aZCSSNIWpVzfU2CYHa2G3P1UN5WF01RdTHSgCkri7XOlTdtk17BezlV3A==",
|
"integrity": "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -1569,9 +1569,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-freebsd-arm64": {
|
"node_modules/@rollup/rollup-freebsd-arm64": {
|
||||||
"version": "4.54.0",
|
"version": "4.59.0",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.54.0.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.59.0.tgz",
|
||||||
"integrity": "sha512-miSvuFkmvFbgJ1BevMa4CPCFt5MPGw094knM64W9I0giUIMMmRYcGW/JWZDriaw/k1kOBtsWh1z6nIFV1vPNtA==",
|
"integrity": "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -1583,9 +1583,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-freebsd-x64": {
|
"node_modules/@rollup/rollup-freebsd-x64": {
|
||||||
"version": "4.54.0",
|
"version": "4.59.0",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.54.0.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.59.0.tgz",
|
||||||
"integrity": "sha512-KGXIs55+b/ZfZsq9aR026tmr/+7tq6VG6MsnrvF4H8VhwflTIuYh+LFUlIsRdQSgrgmtM3fVATzEAj4hBQlaqQ==",
|
"integrity": "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -1597,9 +1597,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-linux-arm-gnueabihf": {
|
"node_modules/@rollup/rollup-linux-arm-gnueabihf": {
|
||||||
"version": "4.54.0",
|
"version": "4.59.0",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.54.0.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.59.0.tgz",
|
||||||
"integrity": "sha512-EHMUcDwhtdRGlXZsGSIuXSYwD5kOT9NVnx9sqzYiwAc91wfYOE1g1djOEDseZJKKqtHAHGwnGPQu3kytmfaXLQ==",
|
"integrity": "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
@@ -1611,9 +1611,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-linux-arm-musleabihf": {
|
"node_modules/@rollup/rollup-linux-arm-musleabihf": {
|
||||||
"version": "4.54.0",
|
"version": "4.59.0",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.54.0.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.59.0.tgz",
|
||||||
"integrity": "sha512-+pBrqEjaakN2ySv5RVrj/qLytYhPKEUwk+e3SFU5jTLHIcAtqh2rLrd/OkbNuHJpsBgxsD8ccJt5ga/SeG0JmA==",
|
"integrity": "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
@@ -1625,9 +1625,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-linux-arm64-gnu": {
|
"node_modules/@rollup/rollup-linux-arm64-gnu": {
|
||||||
"version": "4.54.0",
|
"version": "4.59.0",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.54.0.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.59.0.tgz",
|
||||||
"integrity": "sha512-NSqc7rE9wuUaRBsBp5ckQ5CVz5aIRKCwsoa6WMF7G01sX3/qHUw/z4pv+D+ahL1EIKy6Enpcnz1RY8pf7bjwng==",
|
"integrity": "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -1639,9 +1639,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-linux-arm64-musl": {
|
"node_modules/@rollup/rollup-linux-arm64-musl": {
|
||||||
"version": "4.54.0",
|
"version": "4.59.0",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.54.0.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.59.0.tgz",
|
||||||
"integrity": "sha512-gr5vDbg3Bakga5kbdpqx81m2n9IX8M6gIMlQQIXiLTNeQW6CucvuInJ91EuCJ/JYvc+rcLLsDFcfAD1K7fMofg==",
|
"integrity": "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -1653,9 +1653,23 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-linux-loong64-gnu": {
|
"node_modules/@rollup/rollup-linux-loong64-gnu": {
|
||||||
"version": "4.54.0",
|
"version": "4.59.0",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.54.0.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.59.0.tgz",
|
||||||
"integrity": "sha512-gsrtB1NA3ZYj2vq0Rzkylo9ylCtW/PhpLEivlgWe0bpgtX5+9j9EZa0wtZiCjgu6zmSeZWyI/e2YRX1URozpIw==",
|
"integrity": "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==",
|
||||||
|
"cpu": [
|
||||||
|
"loong64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"node_modules/@rollup/rollup-linux-loong64-musl": {
|
||||||
|
"version": "4.59.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.59.0.tgz",
|
||||||
|
"integrity": "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"loong64"
|
"loong64"
|
||||||
],
|
],
|
||||||
@@ -1667,9 +1681,23 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-linux-ppc64-gnu": {
|
"node_modules/@rollup/rollup-linux-ppc64-gnu": {
|
||||||
"version": "4.54.0",
|
"version": "4.59.0",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.54.0.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.59.0.tgz",
|
||||||
"integrity": "sha512-y3qNOfTBStmFNq+t4s7Tmc9hW2ENtPg8FeUD/VShI7rKxNW7O4fFeaYbMsd3tpFlIg1Q8IapFgy7Q9i2BqeBvA==",
|
"integrity": "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==",
|
||||||
|
"cpu": [
|
||||||
|
"ppc64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"node_modules/@rollup/rollup-linux-ppc64-musl": {
|
||||||
|
"version": "4.59.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.59.0.tgz",
|
||||||
|
"integrity": "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"ppc64"
|
"ppc64"
|
||||||
],
|
],
|
||||||
@@ -1681,9 +1709,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-linux-riscv64-gnu": {
|
"node_modules/@rollup/rollup-linux-riscv64-gnu": {
|
||||||
"version": "4.54.0",
|
"version": "4.59.0",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.54.0.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.59.0.tgz",
|
||||||
"integrity": "sha512-89sepv7h2lIVPsFma8iwmccN7Yjjtgz0Rj/Ou6fEqg3HDhpCa+Et+YSufy27i6b0Wav69Qv4WBNl3Rs6pwhebQ==",
|
"integrity": "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"riscv64"
|
"riscv64"
|
||||||
],
|
],
|
||||||
@@ -1695,9 +1723,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-linux-riscv64-musl": {
|
"node_modules/@rollup/rollup-linux-riscv64-musl": {
|
||||||
"version": "4.54.0",
|
"version": "4.59.0",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.54.0.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.59.0.tgz",
|
||||||
"integrity": "sha512-ZcU77ieh0M2Q8Ur7D5X7KvK+UxbXeDHwiOt/CPSBTI1fBmeDMivW0dPkdqkT4rOgDjrDDBUed9x4EgraIKoR2A==",
|
"integrity": "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"riscv64"
|
"riscv64"
|
||||||
],
|
],
|
||||||
@@ -1709,9 +1737,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-linux-s390x-gnu": {
|
"node_modules/@rollup/rollup-linux-s390x-gnu": {
|
||||||
"version": "4.54.0",
|
"version": "4.59.0",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.54.0.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.59.0.tgz",
|
||||||
"integrity": "sha512-2AdWy5RdDF5+4YfG/YesGDDtbyJlC9LHmL6rZw6FurBJ5n4vFGupsOBGfwMRjBYH7qRQowT8D/U4LoSvVwOhSQ==",
|
"integrity": "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"s390x"
|
"s390x"
|
||||||
],
|
],
|
||||||
@@ -1723,9 +1751,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-linux-x64-gnu": {
|
"node_modules/@rollup/rollup-linux-x64-gnu": {
|
||||||
"version": "4.54.0",
|
"version": "4.59.0",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.54.0.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.59.0.tgz",
|
||||||
"integrity": "sha512-WGt5J8Ij/rvyqpFexxk3ffKqqbLf9AqrTBbWDk7ApGUzaIs6V+s2s84kAxklFwmMF/vBNGrVdYgbblCOFFezMQ==",
|
"integrity": "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -1737,9 +1765,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-linux-x64-musl": {
|
"node_modules/@rollup/rollup-linux-x64-musl": {
|
||||||
"version": "4.54.0",
|
"version": "4.59.0",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.54.0.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.59.0.tgz",
|
||||||
"integrity": "sha512-JzQmb38ATzHjxlPHuTH6tE7ojnMKM2kYNzt44LO/jJi8BpceEC8QuXYA908n8r3CNuG/B3BV8VR3Hi1rYtmPiw==",
|
"integrity": "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -1750,10 +1778,24 @@
|
|||||||
"linux"
|
"linux"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"node_modules/@rollup/rollup-openbsd-x64": {
|
||||||
|
"version": "4.59.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.59.0.tgz",
|
||||||
|
"integrity": "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"openbsd"
|
||||||
|
]
|
||||||
|
},
|
||||||
"node_modules/@rollup/rollup-openharmony-arm64": {
|
"node_modules/@rollup/rollup-openharmony-arm64": {
|
||||||
"version": "4.54.0",
|
"version": "4.59.0",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.54.0.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.59.0.tgz",
|
||||||
"integrity": "sha512-huT3fd0iC7jigGh7n3q/+lfPcXxBi+om/Rs3yiFxjvSxbSB6aohDFXbWvlspaqjeOh+hx7DDHS+5Es5qRkWkZg==",
|
"integrity": "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -1765,9 +1807,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-win32-arm64-msvc": {
|
"node_modules/@rollup/rollup-win32-arm64-msvc": {
|
||||||
"version": "4.54.0",
|
"version": "4.59.0",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.54.0.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.59.0.tgz",
|
||||||
"integrity": "sha512-c2V0W1bsKIKfbLMBu/WGBz6Yci8nJ/ZJdheE0EwB73N3MvHYKiKGs3mVilX4Gs70eGeDaMqEob25Tw2Gb9Nqyw==",
|
"integrity": "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -1779,9 +1821,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-win32-ia32-msvc": {
|
"node_modules/@rollup/rollup-win32-ia32-msvc": {
|
||||||
"version": "4.54.0",
|
"version": "4.59.0",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.54.0.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.59.0.tgz",
|
||||||
"integrity": "sha512-woEHgqQqDCkAzrDhvDipnSirm5vxUXtSKDYTVpZG3nUdW/VVB5VdCYA2iReSj/u3yCZzXID4kuKG7OynPnB3WQ==",
|
"integrity": "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"ia32"
|
"ia32"
|
||||||
],
|
],
|
||||||
@@ -1793,9 +1835,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-win32-x64-gnu": {
|
"node_modules/@rollup/rollup-win32-x64-gnu": {
|
||||||
"version": "4.54.0",
|
"version": "4.59.0",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.54.0.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.59.0.tgz",
|
||||||
"integrity": "sha512-dzAc53LOuFvHwbCEOS0rPbXp6SIhAf2txMP5p6mGyOXXw5mWY8NGGbPMPrs4P1WItkfApDathBj/NzMLUZ9rtQ==",
|
"integrity": "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -1807,9 +1849,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-win32-x64-msvc": {
|
"node_modules/@rollup/rollup-win32-x64-msvc": {
|
||||||
"version": "4.54.0",
|
"version": "4.59.0",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.54.0.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.59.0.tgz",
|
||||||
"integrity": "sha512-hYT5d3YNdSh3mbCU1gwQyPgQd3T2ne0A3KG8KSBdav5TiBg6eInVmV+TeR5uHufiIgSFg0XsOWGW5/RhNcSvPg==",
|
"integrity": "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -2385,9 +2427,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/postcss": {
|
"node_modules/postcss": {
|
||||||
"version": "8.5.6",
|
"version": "8.5.10",
|
||||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
|
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.10.tgz",
|
||||||
"integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==",
|
"integrity": "sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
@@ -2430,10 +2472,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/protobufjs": {
|
"node_modules/protobufjs": {
|
||||||
"version": "7.5.4",
|
"version": "7.5.5",
|
||||||
"resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.5.4.tgz",
|
"resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.5.5.tgz",
|
||||||
"integrity": "sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg==",
|
"integrity": "sha512-3wY1AxV+VBNW8Yypfd1yQY9pXnqTAN+KwQxL8iYm3/BjKYMNg4i0owhEe26PWDOMaIrzeeF98Lqd5NGz4omiIg==",
|
||||||
"hasInstallScript": true,
|
|
||||||
"license": "BSD-3-Clause",
|
"license": "BSD-3-Clause",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@protobufjs/aspromise": "^1.1.2",
|
"@protobufjs/aspromise": "^1.1.2",
|
||||||
@@ -2487,9 +2528,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/react-router": {
|
"node_modules/react-router": {
|
||||||
"version": "7.11.0",
|
"version": "7.12.0",
|
||||||
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.11.0.tgz",
|
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.12.0.tgz",
|
||||||
"integrity": "sha512-uI4JkMmjbWCZc01WVP2cH7ZfSzH91JAZUDd7/nIprDgWxBV1TkkmLToFh7EbMTcMak8URFRa2YoBL/W8GWnCTQ==",
|
"integrity": "sha512-kTPDYPFzDVGIIGNLS5VJykK0HfHLY5MF3b+xj0/tTyNYL1gF1qs7u67Z9jEhQk2sQ98SUaHxlG31g1JtF7IfVw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"cookie": "^1.0.1",
|
"cookie": "^1.0.1",
|
||||||
@@ -2509,12 +2550,12 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/react-router-dom": {
|
"node_modules/react-router-dom": {
|
||||||
"version": "7.11.0",
|
"version": "7.12.0",
|
||||||
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.11.0.tgz",
|
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.12.0.tgz",
|
||||||
"integrity": "sha512-e49Ir/kMGRzFOOrYQBdoitq3ULigw4lKbAyKusnvtDu2t4dBX4AGYPrzNvorXmVuOyeakai6FUPW5MmibvVG8g==",
|
"integrity": "sha512-pfO9fiBcpEfX4Tx+iTYKDtPbrSLLCbwJ5EqP+SPYQu1VYCXdy79GSj0wttR0U4cikVdlImZuEZ/9ZNCgoaxwBA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"react-router": "7.11.0"
|
"react-router": "7.12.0"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=20.0.0"
|
"node": ">=20.0.0"
|
||||||
@@ -2534,9 +2575,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/rollup": {
|
"node_modules/rollup": {
|
||||||
"version": "4.54.0",
|
"version": "4.59.0",
|
||||||
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.54.0.tgz",
|
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz",
|
||||||
"integrity": "sha512-3nk8Y3a9Ea8szgKhinMlGMhGMw89mqule3KWczxhIzqudyHdCIOHw8WJlj/r329fACjKLEh13ZSk7oE22kyeIw==",
|
"integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -2550,28 +2591,31 @@
|
|||||||
"npm": ">=8.0.0"
|
"npm": ">=8.0.0"
|
||||||
},
|
},
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
"@rollup/rollup-android-arm-eabi": "4.54.0",
|
"@rollup/rollup-android-arm-eabi": "4.59.0",
|
||||||
"@rollup/rollup-android-arm64": "4.54.0",
|
"@rollup/rollup-android-arm64": "4.59.0",
|
||||||
"@rollup/rollup-darwin-arm64": "4.54.0",
|
"@rollup/rollup-darwin-arm64": "4.59.0",
|
||||||
"@rollup/rollup-darwin-x64": "4.54.0",
|
"@rollup/rollup-darwin-x64": "4.59.0",
|
||||||
"@rollup/rollup-freebsd-arm64": "4.54.0",
|
"@rollup/rollup-freebsd-arm64": "4.59.0",
|
||||||
"@rollup/rollup-freebsd-x64": "4.54.0",
|
"@rollup/rollup-freebsd-x64": "4.59.0",
|
||||||
"@rollup/rollup-linux-arm-gnueabihf": "4.54.0",
|
"@rollup/rollup-linux-arm-gnueabihf": "4.59.0",
|
||||||
"@rollup/rollup-linux-arm-musleabihf": "4.54.0",
|
"@rollup/rollup-linux-arm-musleabihf": "4.59.0",
|
||||||
"@rollup/rollup-linux-arm64-gnu": "4.54.0",
|
"@rollup/rollup-linux-arm64-gnu": "4.59.0",
|
||||||
"@rollup/rollup-linux-arm64-musl": "4.54.0",
|
"@rollup/rollup-linux-arm64-musl": "4.59.0",
|
||||||
"@rollup/rollup-linux-loong64-gnu": "4.54.0",
|
"@rollup/rollup-linux-loong64-gnu": "4.59.0",
|
||||||
"@rollup/rollup-linux-ppc64-gnu": "4.54.0",
|
"@rollup/rollup-linux-loong64-musl": "4.59.0",
|
||||||
"@rollup/rollup-linux-riscv64-gnu": "4.54.0",
|
"@rollup/rollup-linux-ppc64-gnu": "4.59.0",
|
||||||
"@rollup/rollup-linux-riscv64-musl": "4.54.0",
|
"@rollup/rollup-linux-ppc64-musl": "4.59.0",
|
||||||
"@rollup/rollup-linux-s390x-gnu": "4.54.0",
|
"@rollup/rollup-linux-riscv64-gnu": "4.59.0",
|
||||||
"@rollup/rollup-linux-x64-gnu": "4.54.0",
|
"@rollup/rollup-linux-riscv64-musl": "4.59.0",
|
||||||
"@rollup/rollup-linux-x64-musl": "4.54.0",
|
"@rollup/rollup-linux-s390x-gnu": "4.59.0",
|
||||||
"@rollup/rollup-openharmony-arm64": "4.54.0",
|
"@rollup/rollup-linux-x64-gnu": "4.59.0",
|
||||||
"@rollup/rollup-win32-arm64-msvc": "4.54.0",
|
"@rollup/rollup-linux-x64-musl": "4.59.0",
|
||||||
"@rollup/rollup-win32-ia32-msvc": "4.54.0",
|
"@rollup/rollup-openbsd-x64": "4.59.0",
|
||||||
"@rollup/rollup-win32-x64-gnu": "4.54.0",
|
"@rollup/rollup-openharmony-arm64": "4.59.0",
|
||||||
"@rollup/rollup-win32-x64-msvc": "4.54.0",
|
"@rollup/rollup-win32-arm64-msvc": "4.59.0",
|
||||||
|
"@rollup/rollup-win32-ia32-msvc": "4.59.0",
|
||||||
|
"@rollup/rollup-win32-x64-gnu": "4.59.0",
|
||||||
|
"@rollup/rollup-win32-x64-msvc": "4.59.0",
|
||||||
"fsevents": "~2.3.2"
|
"fsevents": "~2.3.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -2728,9 +2772,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/vite": {
|
"node_modules/vite": {
|
||||||
"version": "7.3.0",
|
"version": "7.3.2",
|
||||||
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.2.tgz",
|
||||||
"integrity": "sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg==",
|
"integrity": "sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
"peer": true,
|
||||||
|
|||||||
@@ -23,7 +23,7 @@
|
|||||||
"firebase": "^12.7.0",
|
"firebase": "^12.7.0",
|
||||||
"react": "^19.2.3",
|
"react": "^19.2.3",
|
||||||
"react-dom": "^19.2.3",
|
"react-dom": "^19.2.3",
|
||||||
"react-router-dom": "^7.11.0"
|
"react-router-dom": "^7.12.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/react": "^19.2.7",
|
"@types/react": "^19.2.7",
|
||||||
@@ -31,6 +31,6 @@
|
|||||||
"@vitejs/plugin-react": "^5.1.2",
|
"@vitejs/plugin-react": "^5.1.2",
|
||||||
"prettier": "^3.8.1",
|
"prettier": "^3.8.1",
|
||||||
"typescript": "^5.9.3",
|
"typescript": "^5.9.3",
|
||||||
"vite": "^7.3.0"
|
"vite": "^7.3.2"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
BIN
backend/poller → frontend/public/koin_exp_2.mp4
Executable file → Normal file
BIN
backend/poller → frontend/public/koin_exp_2.mp4
Executable file → Normal file
Binary file not shown.
@@ -1,16 +1,21 @@
|
|||||||
import { Routes, Route, Navigate } from "react-router-dom";
|
import { Routes, Route, Navigate } from "react-router-dom";
|
||||||
import { useAuth } from "./contexts/AuthContext";
|
import { useAuth } from "./contexts/AuthContext";
|
||||||
import Navbar from "./components/Navbar";
|
import Navbar from "./components/Navbar";
|
||||||
|
import Footer from "./components/Footer";
|
||||||
import Login from "./pages/login/Login";
|
import Login from "./pages/login/Login";
|
||||||
import Signup from "./pages/Signup";
|
import Signup from "./pages/Signup";
|
||||||
import Subscribe from "./pages/subscribe/subscribe";
|
import Subscribe from "./pages/subscribe/Subscribe";
|
||||||
|
import CheckoutReturn from "./pages/subscribe/CheckoutReturn";
|
||||||
import Addresses from "./pages/addresses/Addresses";
|
import Addresses from "./pages/addresses/Addresses";
|
||||||
import Alerts from "./pages/alerts/Alerts";
|
import Alerts from "./pages/alerts/Alerts";
|
||||||
import AlertHistory from "./pages/alertHistory/AlertHistory";
|
import AlertHistory from "./pages/alertHistory/AlertHistory";
|
||||||
import Account from "./pages/user_account/Account";
|
import Account from "./pages/user_account/Account";
|
||||||
|
import Terms from "./pages/terms/Terms";
|
||||||
|
import Privacy from "./pages/privacy/Privacy";
|
||||||
|
import Support from "./pages/support/Support";
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
const { currentUser } = useAuth();
|
const { currentUser, isSubscribed } = useAuth();
|
||||||
|
|
||||||
if (!currentUser) {
|
if (!currentUser) {
|
||||||
return (
|
return (
|
||||||
@@ -18,23 +23,50 @@ export default function App() {
|
|||||||
<Route path="/login" element={<Login />} />
|
<Route path="/login" element={<Login />} />
|
||||||
<Route path="/signup" element={<Signup />} />
|
<Route path="/signup" element={<Signup />} />
|
||||||
<Route path="/subscribe" element={<Subscribe />} />
|
<Route path="/subscribe" element={<Subscribe />} />
|
||||||
|
<Route path="/subscribe/return/:sessionId" element={<CheckoutReturn />} />
|
||||||
<Route path="*" element={<Navigate to="/login" />} />
|
<Route path="*" element={<Navigate to="/login" />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!isSubscribed) {
|
||||||
|
return (
|
||||||
|
<div className="app-layout">
|
||||||
|
<div className="app-layout__main">
|
||||||
|
<Routes>
|
||||||
|
<Route path="/subscribe" element={<Subscribe />} />
|
||||||
|
<Route path="/subscribe/return/:sessionId" element={<CheckoutReturn />} />
|
||||||
|
<Route path="/account" element={<><Navbar /><Account /></>} />
|
||||||
|
<Route path="/terms" element={<><Navbar /><Terms /></>} />
|
||||||
|
<Route path="/privacy" element={<><Navbar /><Privacy /></>} />
|
||||||
|
<Route path="/support" element={<><Navbar /><Support /></>} />
|
||||||
|
<Route path="*" element={<Navigate to="/subscribe" />} />
|
||||||
|
</Routes>
|
||||||
|
</div>
|
||||||
|
<Footer />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div className="app-layout">
|
||||||
<Navbar />
|
<Navbar />
|
||||||
<Routes>
|
<div className="app-layout__main">
|
||||||
<Route path="/" element={<Addresses />} />
|
<Routes>
|
||||||
<Route path="/addresses" element={<Addresses />} />
|
<Route path="/" element={<Addresses />} />
|
||||||
<Route path="/alerts" element={<Alerts />} />
|
<Route path="/addresses" element={<Addresses />} />
|
||||||
<Route path="/alertevents" element={<AlertHistory />} />
|
<Route path="/alerts" element={<Alerts />} />
|
||||||
<Route path="/account" element={<Account />} />
|
<Route path="/alertevents" element={<AlertHistory />} />
|
||||||
<Route path="/subscribe" element={<Subscribe />} />
|
<Route path="/account" element={<Account />} />
|
||||||
<Route path="*" element={<Navigate to="/addresses" />} />
|
<Route path="/subscribe" element={<Subscribe />} />
|
||||||
</Routes>
|
<Route path="/subscribe/return/:sessionId" element={<CheckoutReturn />} />
|
||||||
|
<Route path="/terms" element={<Terms />} />
|
||||||
|
<Route path="/privacy" element={<Privacy />} />
|
||||||
|
<Route path="/support" element={<Support />} />
|
||||||
|
<Route path="*" element={<Navigate to="/addresses" />} />
|
||||||
|
</Routes>
|
||||||
|
</div>
|
||||||
|
<Footer />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -129,6 +129,48 @@ export async function updateAlertStatus(alertId, enabled) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update the min/max thresholds on an alert rule
|
||||||
|
* @param {number} alertId - Alert rule ID
|
||||||
|
* @param {number|null} minimum - Minimum amount (null to clear)
|
||||||
|
* @param {number|null} maximum - Maximum amount (null to clear)
|
||||||
|
* @returns {Promise<Object>} Updated alert rule
|
||||||
|
*/
|
||||||
|
export async function updateAlertThresholds(alertId, minimum, maximum) {
|
||||||
|
try {
|
||||||
|
const headers = await getAuthHeaders();
|
||||||
|
const response = await fetch(`${API_BASE}/alerts/${alertId}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: headers,
|
||||||
|
body: JSON.stringify({
|
||||||
|
update_min_max: true,
|
||||||
|
minimum: minimum ?? null,
|
||||||
|
maximum: maximum ?? null,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
let errorMessage = "Failed to update alert thresholds";
|
||||||
|
try {
|
||||||
|
const error = await response.json();
|
||||||
|
errorMessage = error.message || errorMessage;
|
||||||
|
} catch {
|
||||||
|
errorMessage = `Server error: ${response.status} ${response.statusText}`;
|
||||||
|
}
|
||||||
|
throw new Error(errorMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json();
|
||||||
|
} catch (error) {
|
||||||
|
if (error.message.includes("fetch")) {
|
||||||
|
throw new Error(
|
||||||
|
"Cannot connect to server. Is the backend running?",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Delete an alert rule
|
* Delete an alert rule
|
||||||
* @param {number} alertId - Alert rule ID
|
* @param {number} alertId - Alert rule ID
|
||||||
|
|||||||
@@ -1 +1,20 @@
|
|||||||
export const API_BASE = import.meta.env.VITE_API_BASE || "/v1";
|
const DEFAULT_API_BASE = "/v1";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* API origin for fetch(): root-relative prefix (e.g. /v1) or absolute URL (https://host/v1).
|
||||||
|
* Root-relative values are forced to start with "/" so requests work from any SPA route
|
||||||
|
* (e.g. /support); otherwise "v1/support" would resolve under the current path and 404.
|
||||||
|
*/
|
||||||
|
function normalizeApiBase(raw) {
|
||||||
|
const s = String(raw ?? "").trim();
|
||||||
|
const base = s || DEFAULT_API_BASE;
|
||||||
|
|
||||||
|
if (/^https?:\/\//i.test(base)) {
|
||||||
|
return base.replace(/\/+$/, "") || DEFAULT_API_BASE;
|
||||||
|
}
|
||||||
|
|
||||||
|
const path = base.startsWith("/") ? base : `/${base}`;
|
||||||
|
return path.replace(/\/+$/, "") || DEFAULT_API_BASE;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const API_BASE = normalizeApiBase(import.meta.env.VITE_API_BASE);
|
||||||
|
|||||||
@@ -1,15 +1,42 @@
|
|||||||
import { getAuthHeaders } from "./authHeaders";
|
import { getAuthHeaders } from "./authHeaders";
|
||||||
import { API_BASE } from "./config";
|
import { API_BASE } from "./config";
|
||||||
|
|
||||||
export async function createCheckoutSession() {
|
export async function createCheckoutSession(tier = "premium", interval = "annual") {
|
||||||
const headers = await getAuthHeaders();
|
const headers = await getAuthHeaders();
|
||||||
const res = await fetch(`${API_BASE}/stripe/create-checkout-session`, {
|
const res = await fetch(`${API_BASE}/stripe/create-checkout-session`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { ...headers, "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ tier, interval }),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
throw new Error(data.message || "Failed to create checkout session");
|
||||||
|
}
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createOnboardingCheckout(email, tier = "premium") {
|
||||||
|
const res = await fetch(`${API_BASE}/stripe/create-onboarding-checkout`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ email, tier }),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
throw new Error(data.message || "Failed to create checkout session");
|
||||||
|
}
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function activateFreeTier() {
|
||||||
|
const headers = await getAuthHeaders();
|
||||||
|
const res = await fetch(`${API_BASE}/stripe/activate-free`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers,
|
headers,
|
||||||
});
|
});
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
throw new Error(data.message || "Failed to create checkout session");
|
throw new Error(data.message || "Failed to activate free tier");
|
||||||
}
|
}
|
||||||
return res.json();
|
return res.json();
|
||||||
}
|
}
|
||||||
|
|||||||
23
frontend/src/api/support.js
Normal file
23
frontend/src/api/support.js
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import { getAuthHeaders } from "./authHeaders";
|
||||||
|
import { API_BASE } from "./config";
|
||||||
|
|
||||||
|
export async function submitSupportRequest({ email, description }) {
|
||||||
|
const headers = await getAuthHeaders();
|
||||||
|
const res = await fetch(`${API_BASE}/support`, {
|
||||||
|
method: "POST",
|
||||||
|
headers,
|
||||||
|
body: JSON.stringify({ email, description }),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
let message = "Failed to send support request";
|
||||||
|
try {
|
||||||
|
const data = await res.json();
|
||||||
|
if (data.message) message = data.message;
|
||||||
|
else if (data.error && res.status) message = `${data.error} (${res.status})`;
|
||||||
|
} catch {
|
||||||
|
message = `Request failed (HTTP ${res.status}). The server did not return JSON — check that POST ${API_BASE}/support is proxied to your Go API (e.g. nginx location /v1/).`;
|
||||||
|
}
|
||||||
|
throw new Error(message);
|
||||||
|
}
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
36
frontend/src/components/Footer.css
Normal file
36
frontend/src/components/Footer.css
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
.footer {
|
||||||
|
flex-shrink: 0;
|
||||||
|
margin-top: auto;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
background-color: var(--color-bg);
|
||||||
|
border-top: 1px solid var(--color-border-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer__inner {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem 1rem;
|
||||||
|
max-width: 1400px;
|
||||||
|
margin: 0 auto;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
line-height: 1.4;
|
||||||
|
color: var(--color-text-dimmed);
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer__copyright {
|
||||||
|
color: var(--color-text-dimmed);
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer__link {
|
||||||
|
font-size: 1.265em;
|
||||||
|
color: inherit;
|
||||||
|
text-decoration: none;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer__link:hover {
|
||||||
|
color: var(--color-primary);
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
41
frontend/src/components/Footer.jsx
Normal file
41
frontend/src/components/Footer.jsx
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import { Link } from "react-router-dom";
|
||||||
|
import { useAuth } from "../contexts/AuthContext";
|
||||||
|
import "./Footer.css";
|
||||||
|
|
||||||
|
export default function Footer() {
|
||||||
|
const { currentUser } = useAuth();
|
||||||
|
|
||||||
|
if (!currentUser) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<footer className="footer">
|
||||||
|
<div className="footer__inner">
|
||||||
|
<span className="footer__copyright">© 2026 sjDev.co</span>
|
||||||
|
<Link
|
||||||
|
to="/terms"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="footer__link"
|
||||||
|
>
|
||||||
|
Terms
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
to="/privacy"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="footer__link"
|
||||||
|
>
|
||||||
|
Privacy
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
to="/support"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="footer__link"
|
||||||
|
>
|
||||||
|
Contact Support
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -8,6 +8,19 @@
|
|||||||
border-bottom: 1px solid var(--color-border-light);
|
border-bottom: 1px solid var(--color-border-light);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Left Group: Brand + Links ───────────────────────────── */
|
||||||
|
|
||||||
|
.navbar__left {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar__brand-group {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
.navbar__brand {
|
.navbar__brand {
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
font-size: 1.4rem;
|
font-size: 1.4rem;
|
||||||
@@ -38,6 +51,14 @@
|
|||||||
background-color: var(--color-bg-card);
|
background-color: var(--color-bg-card);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Right Group: Email + Logout + Hamburger ─────────────── */
|
||||||
|
|
||||||
|
.navbar__right {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
.navbar__user {
|
.navbar__user {
|
||||||
font-size: 1.275rem;
|
font-size: 1.275rem;
|
||||||
color: #777;
|
color: #777;
|
||||||
@@ -53,6 +74,10 @@
|
|||||||
color: var(--color-primary-hover);
|
color: var(--color-primary-hover);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.navbar__user-link--mobile {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
.navbar__logout {
|
.navbar__logout {
|
||||||
padding: 0.4rem 1rem;
|
padding: 0.4rem 1rem;
|
||||||
font-size: 1.275rem;
|
font-size: 1.275rem;
|
||||||
@@ -65,59 +90,78 @@
|
|||||||
transition: all 0.15s ease;
|
transition: all 0.15s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Navbar Responsive ───────────────────────────────────── */
|
/* ── Hamburger Icon (hidden on desktop) ──────────────────── */
|
||||||
|
|
||||||
|
.navbar__hamburger {
|
||||||
|
display: none;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 5px;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar__hamburger-bar {
|
||||||
|
display: block;
|
||||||
|
width: 28px;
|
||||||
|
height: 3px;
|
||||||
|
background-color: var(--color-brand);
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Responsive: Tablet & below (≤ 768px) ────────────────── */
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.navbar {
|
.navbar {
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 0.75rem;
|
|
||||||
padding: 0.75rem 1rem;
|
padding: 0.75rem 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.navbar__brand {
|
.navbar__brand-group {
|
||||||
margin-right: 1rem;
|
|
||||||
font-size: 1.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.navbar__links {
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 0.2rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.navbar__link {
|
|
||||||
padding: 0.35rem 0.75rem;
|
|
||||||
font-size: 1.2rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.navbar__user {
|
|
||||||
font-size: 1.1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.navbar__logout {
|
|
||||||
padding: 0.3rem 0.75rem;
|
|
||||||
font-size: 1.1rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 480px) {
|
|
||||||
.navbar {
|
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
padding: 0.75rem;
|
|
||||||
gap: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.navbar__links {
|
|
||||||
width: 100%;
|
|
||||||
gap: 0.15rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.navbar__link {
|
|
||||||
padding: 0.3rem 0.6rem;
|
|
||||||
font-size: 1.05rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.navbar__brand {
|
.navbar__brand {
|
||||||
margin-right: 0;
|
margin-right: 0;
|
||||||
|
font-size: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar__links {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar__user-link--mobile {
|
||||||
|
display: block;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar__user-link--desktop {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar__logout {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar__hamburger {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Responsive: Mobile (≤ 480px) ────────────────────────── */
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.navbar {
|
||||||
|
padding: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar__brand {
|
||||||
|
font-size: 1.15rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar__user-link--mobile {
|
||||||
|
font-size: 0.85rem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
|
import { useState } from "react";
|
||||||
import { Link, useLocation } from "react-router-dom";
|
import { Link, useLocation } from "react-router-dom";
|
||||||
import { useAuth } from "../contexts/AuthContext";
|
import { useAuth } from "../contexts/AuthContext";
|
||||||
|
import NavPanel from "./navPanel/NavPanel";
|
||||||
import "./Navbar.css";
|
import "./Navbar.css";
|
||||||
|
|
||||||
const navLinks = [
|
const navLinks = [
|
||||||
@@ -11,35 +13,59 @@ const navLinks = [
|
|||||||
export default function Navbar() {
|
export default function Navbar() {
|
||||||
const { currentUser, logout } = useAuth();
|
const { currentUser, logout } = useAuth();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
|
const [isNavPanelOpen, setIsNavPanelOpen] = useState(false);
|
||||||
|
|
||||||
if (!currentUser) return null;
|
if (!currentUser) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<nav className="navbar">
|
<>
|
||||||
<div className="flex flex--center gap-sm">
|
<nav className="navbar">
|
||||||
<span className="navbar__brand">Koin Ping</span>
|
<div className="navbar__left">
|
||||||
<div className="navbar__links">
|
<div className="navbar__brand-group">
|
||||||
{navLinks.map(({ to, label }) => {
|
<span className="navbar__brand">Koin Ping</span>
|
||||||
const isActive = location.pathname === to;
|
<Link to="/account" className="navbar__user-link navbar__user-link--mobile">
|
||||||
return (
|
{currentUser.email}
|
||||||
<Link
|
</Link>
|
||||||
key={to}
|
</div>
|
||||||
to={to}
|
<div className="navbar__links">
|
||||||
className={`navbar__link ${isActive ? "navbar__link--active" : ""}`}
|
{navLinks.map(({ to, label }) => {
|
||||||
>
|
const isActive = location.pathname === to;
|
||||||
{label}
|
return (
|
||||||
</Link>
|
<Link
|
||||||
);
|
key={to}
|
||||||
})}
|
to={to}
|
||||||
|
className={`navbar__link ${isActive ? "navbar__link--active" : ""}`}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex--center gap-lg">
|
<div className="navbar__right">
|
||||||
<Link to="/account" className="navbar__user navbar__user-link">{currentUser.email}</Link>
|
<Link to="/account" className="navbar__user navbar__user-link navbar__user-link--desktop">
|
||||||
<button onClick={logout} className="navbar__logout">
|
{currentUser.email}
|
||||||
Logout
|
</Link>
|
||||||
</button>
|
<button onClick={logout} className="navbar__logout">
|
||||||
</div>
|
Logout
|
||||||
</nav>
|
</button>
|
||||||
|
<button
|
||||||
|
className="navbar__hamburger"
|
||||||
|
onClick={() => setIsNavPanelOpen(true)}
|
||||||
|
aria-label="Open navigation menu"
|
||||||
|
>
|
||||||
|
<span className="navbar__hamburger-bar" />
|
||||||
|
<span className="navbar__hamburger-bar" />
|
||||||
|
<span className="navbar__hamburger-bar" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<NavPanel
|
||||||
|
isOpen={isNavPanelOpen}
|
||||||
|
onClose={() => setIsNavPanelOpen(false)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
161
frontend/src/components/TierPicker.css
Normal file
161
frontend/src/components/TierPicker.css
Normal file
@@ -0,0 +1,161 @@
|
|||||||
|
.tier-picker {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tier-picker__toggle {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tier-picker__toggle-btn {
|
||||||
|
padding: 0.75rem 1.5rem;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
background: transparent;
|
||||||
|
color: var(--color-text-dimmed);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tier-picker__toggle-btn:first-child {
|
||||||
|
border-radius: 8px 0 0 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tier-picker__toggle-btn:last-child {
|
||||||
|
border-radius: 0 8px 8px 0;
|
||||||
|
border-left: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tier-picker__toggle-btn--active {
|
||||||
|
background: var(--color-primary);
|
||||||
|
color: white;
|
||||||
|
border-color: var(--color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tier-picker__toggle-btn:last-child.tier-picker__toggle-btn--active {
|
||||||
|
border-left: 1px solid var(--color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tier-picker__cards {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tier-picker__card {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
padding: 1.5rem 1.25rem;
|
||||||
|
background-color: var(--color-bg-elevated);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-xl);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tier-picker__card--highlighted {
|
||||||
|
border-color: var(--color-primary);
|
||||||
|
box-shadow: 0 0 0 1px var(--color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tier-picker__card--selected {
|
||||||
|
border-color: var(--color-success);
|
||||||
|
box-shadow: 0 0 0 1px var(--color-success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tier-picker__badge {
|
||||||
|
position: absolute;
|
||||||
|
top: -10px;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
background-color: var(--color-primary);
|
||||||
|
color: white;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
font-weight: 700;
|
||||||
|
padding: 2px 12px;
|
||||||
|
border-radius: 10px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tier-picker__name {
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
font-size: 1.05rem;
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tier-picker__price {
|
||||||
|
margin-bottom: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tier-picker__amount {
|
||||||
|
font-size: 2rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tier-picker__period {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--color-text-dimmed);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tier-picker__features {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0 0 1.25rem;
|
||||||
|
text-align: left;
|
||||||
|
flex-grow: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tier-picker__feature {
|
||||||
|
padding: 0.3rem 0;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
color: var(--color-text-label);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tier-picker__feature--disabled {
|
||||||
|
color: var(--color-text-dimmed);
|
||||||
|
opacity: 0.55;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tier-picker__check {
|
||||||
|
color: var(--color-primary);
|
||||||
|
font-weight: bold;
|
||||||
|
margin-right: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tier-picker__dash {
|
||||||
|
margin-right: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tier-picker__btn {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.6rem;
|
||||||
|
background-color: var(--color-bg-card);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
color: var(--color-text);
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tier-picker__btn:hover {
|
||||||
|
background-color: var(--color-primary);
|
||||||
|
border-color: var(--color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tier-picker__btn--selected {
|
||||||
|
background-color: var(--color-success);
|
||||||
|
border-color: var(--color-success);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.tier-picker__cards {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
max-width: 400px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
113
frontend/src/components/TierPicker.jsx
Normal file
113
frontend/src/components/TierPicker.jsx
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import "./TierPicker.css";
|
||||||
|
|
||||||
|
const TIERS = [
|
||||||
|
{
|
||||||
|
id: "free",
|
||||||
|
name: "Trial Monitoring",
|
||||||
|
price: { monthly: "$0", annual: "$0" },
|
||||||
|
period: { monthly: "", annual: "" },
|
||||||
|
features: [
|
||||||
|
"Monitor 1 blockchain address 24/7",
|
||||||
|
"Configure alerts to fire on trigger events",
|
||||||
|
"1 transaction alert type per trigger (email digest)",
|
||||||
|
],
|
||||||
|
disabledFeatures: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "premium",
|
||||||
|
name: "Premium Monitoring",
|
||||||
|
price: { monthly: "$8.78", annual: "$94.78" },
|
||||||
|
period: { monthly: "/month", annual: "/year" },
|
||||||
|
features: [
|
||||||
|
"Monitor 3 blockchain addresses",
|
||||||
|
"Configure two types of rule-based alerts to fire on trigger events for each of the three addresses",
|
||||||
|
"Daily email digest alert",
|
||||||
|
"Real-time Discord alerts",
|
||||||
|
"Real-time Telegram alerts",
|
||||||
|
],
|
||||||
|
disabledFeatures: [],
|
||||||
|
highlighted: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "pro",
|
||||||
|
name: "Professional Monitoring",
|
||||||
|
price: { monthly: "$16.78", annual: "$181.78" },
|
||||||
|
period: { monthly: "/month", annual: "/year" },
|
||||||
|
features: [
|
||||||
|
"Monitor unlimited blockchain addresses",
|
||||||
|
"Configure unlimited alert rules to fire on unlimited events on any address",
|
||||||
|
"Daily email digest alert",
|
||||||
|
"Real-time Discord alerts",
|
||||||
|
"Real-time Telegram alerts",
|
||||||
|
"Real-time Slack alerts configurable for multiple Slack groups or channels",
|
||||||
|
"Unlimited transaction alert types per monitored address",
|
||||||
|
],
|
||||||
|
disabledFeatures: [],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function TierPicker({ onSelect, selectedTier }) {
|
||||||
|
const [isAnnual, setIsAnnual] = useState(true);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="tier-picker">
|
||||||
|
<div className="tier-picker__toggle">
|
||||||
|
<button
|
||||||
|
className={`tier-picker__toggle-btn${!isAnnual ? " tier-picker__toggle-btn--active" : ""}`}
|
||||||
|
onClick={() => setIsAnnual(false)}
|
||||||
|
>
|
||||||
|
Monthly
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={`tier-picker__toggle-btn${isAnnual ? " tier-picker__toggle-btn--active" : ""}`}
|
||||||
|
onClick={() => setIsAnnual(true)}
|
||||||
|
>
|
||||||
|
Annual
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="tier-picker__cards">
|
||||||
|
{TIERS.map((tier) => (
|
||||||
|
<div
|
||||||
|
key={tier.id}
|
||||||
|
className={`tier-picker__card${tier.highlighted ? " tier-picker__card--highlighted" : ""}${selectedTier === tier.id ? " tier-picker__card--selected" : ""}`}
|
||||||
|
>
|
||||||
|
{tier.highlighted && (
|
||||||
|
<div className="tier-picker__badge">Most Popular</div>
|
||||||
|
)}
|
||||||
|
<h3 className="tier-picker__name">{tier.name}</h3>
|
||||||
|
<div className="tier-picker__price">
|
||||||
|
<span className="tier-picker__amount">
|
||||||
|
{isAnnual ? tier.price.annual : tier.price.monthly}
|
||||||
|
</span>
|
||||||
|
{(isAnnual ? tier.period.annual : tier.period.monthly) && (
|
||||||
|
<span className="tier-picker__period">
|
||||||
|
{isAnnual ? tier.period.annual : tier.period.monthly}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<ul className="tier-picker__features">
|
||||||
|
{tier.features.map((f) => (
|
||||||
|
<li key={f} className="tier-picker__feature">
|
||||||
|
<span className="tier-picker__check">✓</span> {f}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
{tier.disabledFeatures.map((f) => (
|
||||||
|
<li key={f} className="tier-picker__feature tier-picker__feature--disabled">
|
||||||
|
<span className="tier-picker__dash">—</span> {f}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
<button
|
||||||
|
className={`btn tier-picker__btn${selectedTier === tier.id ? " tier-picker__btn--selected" : ""}`}
|
||||||
|
onClick={() => onSelect(tier.id, isAnnual ? "annual" : "monthly")}
|
||||||
|
>
|
||||||
|
{selectedTier === tier.id ? "Selected" : "Select"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
32
frontend/src/components/UpgradeBanner.css
Normal file
32
frontend/src/components/UpgradeBanner.css
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
.upgrade-banner {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
background-color: #3a2e00;
|
||||||
|
border: 1px solid #aa7700;
|
||||||
|
color: #ffcc44;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upgrade-banner__message {
|
||||||
|
font-size: 0.88rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upgrade-banner__link {
|
||||||
|
background: none;
|
||||||
|
border: 1px solid #aa7700;
|
||||||
|
color: #ffcc44;
|
||||||
|
padding: 0.25rem 0.75rem;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
font-weight: 600;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upgrade-banner__link:hover {
|
||||||
|
background-color: #4a3800;
|
||||||
|
}
|
||||||
18
frontend/src/components/UpgradeBanner.jsx
Normal file
18
frontend/src/components/UpgradeBanner.jsx
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import "./UpgradeBanner.css";
|
||||||
|
|
||||||
|
export default function UpgradeBanner({ message, linkTo = "/subscribe?upgrade=true" }) {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="upgrade-banner">
|
||||||
|
<span className="upgrade-banner__message">{message}</span>
|
||||||
|
<button
|
||||||
|
className="upgrade-banner__link"
|
||||||
|
onClick={() => navigate(linkTo)}
|
||||||
|
>
|
||||||
|
Upgrade
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
89
frontend/src/components/navPanel/NavPanel.css
Normal file
89
frontend/src/components/navPanel/NavPanel.css
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
/* ── Overlay Backdrop ─────────────────────────────────────── */
|
||||||
|
|
||||||
|
.nav-panel__overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background-color: rgba(0, 0, 0, 0.45);
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
transition: opacity 0.3s ease;
|
||||||
|
z-index: 900;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-panel__overlay--visible {
|
||||||
|
opacity: 1;
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Slide-out Panel ─────────────────────────────────────── */
|
||||||
|
|
||||||
|
.nav-panel {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 260px;
|
||||||
|
height: 100%;
|
||||||
|
background-color: var(--color-bg-elevated);
|
||||||
|
border-right: 1px solid var(--color-border);
|
||||||
|
transform: translateX(-100%);
|
||||||
|
transition: transform 0.3s ease;
|
||||||
|
z-index: 950;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
padding: 1.5rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-panel--open {
|
||||||
|
transform: translateX(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Nav Links ───────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.nav-panel__nav {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-panel__link {
|
||||||
|
padding: 0.9rem 1.5rem;
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: 1.3rem;
|
||||||
|
font-weight: 200;
|
||||||
|
color: var(--color-text-subtle);
|
||||||
|
border-left: 3px solid transparent;
|
||||||
|
transition: background-color 0.15s ease, color 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-panel__link--active {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-text);
|
||||||
|
background-color: var(--color-bg-card);
|
||||||
|
border-left-color: var(--color-brand);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Logout Button ───────────────────────────────────────── */
|
||||||
|
|
||||||
|
.nav-panel__logout {
|
||||||
|
margin: 1rem 1.5rem;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
font-size: 1.15rem;
|
||||||
|
font-weight: 400;
|
||||||
|
background-color: transparent;
|
||||||
|
color: var(--color-text-subtle);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.nav-panel__overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background-color: rgba(0, 0, 0, 0.45);
|
||||||
|
opacity: 0;
|
||||||
|
z-index: 900;
|
||||||
|
}
|
||||||
|
}
|
||||||
52
frontend/src/components/navPanel/NavPanel.jsx
Normal file
52
frontend/src/components/navPanel/NavPanel.jsx
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
import { Link, useLocation } from "react-router-dom";
|
||||||
|
import { useAuth } from "../../contexts/AuthContext";
|
||||||
|
import "./NavPanel.css";
|
||||||
|
|
||||||
|
const navLinks = [
|
||||||
|
{ to: "/addresses", label: "Addresses" },
|
||||||
|
{ to: "/alerts", label: "Configure Alerts" },
|
||||||
|
{ to: "/alertevents", label: "Alert Event History" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function NavPanel({ isOpen, onClose }) {
|
||||||
|
const { logout } = useAuth();
|
||||||
|
const location = useLocation();
|
||||||
|
|
||||||
|
const handleNavClick = () => {
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleLogout = () => {
|
||||||
|
onClose();
|
||||||
|
logout();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
className={`nav-panel__overlay${isOpen ? " nav-panel__overlay--visible" : ""}`}
|
||||||
|
onClick={onClose}
|
||||||
|
/>
|
||||||
|
<aside className={`nav-panel${isOpen ? " nav-panel--open" : ""}`}>
|
||||||
|
<nav className="nav-panel__nav">
|
||||||
|
{navLinks.map(({ to, label }) => {
|
||||||
|
const isActive = location.pathname === to;
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={to}
|
||||||
|
to={to}
|
||||||
|
className={`nav-panel__link${isActive ? " nav-panel__link--active" : ""}`}
|
||||||
|
onClick={handleNavClick}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</nav>
|
||||||
|
<button onClick={handleLogout} className="nav-panel__logout">
|
||||||
|
Logout
|
||||||
|
</button>
|
||||||
|
</aside>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,114 +1,122 @@
|
|||||||
/**
|
import { createContext, useContext, useSyncExternalStore } from "react";
|
||||||
* AuthContext - Firebase Authentication State Management
|
|
||||||
*
|
|
||||||
* Provides authentication state and methods throughout the app
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { createContext, useContext, useEffect, useState } from "react";
|
|
||||||
import {
|
import {
|
||||||
createUserWithEmailAndPassword,
|
onAuthStateChanged,
|
||||||
signInWithEmailAndPassword,
|
signInWithEmailAndPassword,
|
||||||
signOut,
|
createUserWithEmailAndPassword,
|
||||||
onAuthStateChanged,
|
signOut,
|
||||||
|
sendEmailVerification as firebaseSendEmailVerification,
|
||||||
} from "firebase/auth";
|
} from "firebase/auth";
|
||||||
import { auth } from "../firebase/config";
|
import { auth } from "../firebase/config";
|
||||||
|
import { getAccount } from "../api/account";
|
||||||
|
|
||||||
const AuthContext = createContext();
|
const AuthContext = createContext(undefined);
|
||||||
|
|
||||||
/**
|
const DEFAULT_TIER_LIMITS = {
|
||||||
* Hook to access auth context
|
max_addresses: 1,
|
||||||
* @returns {Object} Auth context value
|
max_alert_types: 1,
|
||||||
*/
|
allowed_channels: ["email"],
|
||||||
export function useAuth() {
|
};
|
||||||
const context = useContext(AuthContext);
|
|
||||||
if (!context) {
|
// External auth store - lives outside React
|
||||||
throw new Error("useAuth must be used within AuthProvider");
|
const createAuthStore = () => {
|
||||||
|
let state = {
|
||||||
|
currentUser: null,
|
||||||
|
userTier: "free",
|
||||||
|
tierLimits: DEFAULT_TIER_LIMITS,
|
||||||
|
isSubscribed: false,
|
||||||
|
loading: true,
|
||||||
|
};
|
||||||
|
const listeners = new Set();
|
||||||
|
|
||||||
|
const notify = () => listeners.forEach((fn) => fn());
|
||||||
|
|
||||||
|
const setState = (partial) => {
|
||||||
|
state = { ...state, ...partial };
|
||||||
|
notify();
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchAccount = async () => {
|
||||||
|
try {
|
||||||
|
const data = await getAccount();
|
||||||
|
setState({
|
||||||
|
userTier: data.subscription_tier || "free",
|
||||||
|
tierLimits: data.tier_limits || DEFAULT_TIER_LIMITS,
|
||||||
|
isSubscribed:
|
||||||
|
data.subscription_status === "active" ||
|
||||||
|
data.subscription_status === "trialing",
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
setState({ isSubscribed: false });
|
||||||
}
|
}
|
||||||
return context;
|
};
|
||||||
}
|
|
||||||
|
// Set up Firebase listener once, outside of React
|
||||||
|
onAuthStateChanged(auth, async (user) => {
|
||||||
|
setState({ loading: true, currentUser: user });
|
||||||
|
if (user) {
|
||||||
|
await fetchAccount();
|
||||||
|
} else {
|
||||||
|
setState({ isSubscribed: false, userTier: "free", tierLimits: DEFAULT_TIER_LIMITS });
|
||||||
|
}
|
||||||
|
setState({ loading: false });
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
subscribe: (listener) => {
|
||||||
|
listeners.add(listener);
|
||||||
|
return () => listeners.delete(listener);
|
||||||
|
},
|
||||||
|
getSnapshot: () => state,
|
||||||
|
refreshAccount: fetchAccount,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const authStore = createAuthStore();
|
||||||
|
|
||||||
/**
|
|
||||||
* AuthProvider - Wraps app and provides auth state
|
|
||||||
*/
|
|
||||||
export function AuthProvider({ children }) {
|
export function AuthProvider({ children }) {
|
||||||
const [currentUser, setCurrentUser] = useState(null);
|
const state = useSyncExternalStore(authStore.subscribe, authStore.getSnapshot);
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [error, setError] = useState(null);
|
|
||||||
|
|
||||||
/**
|
const signup = (email, password) => createUserWithEmailAndPassword(auth, email, password);
|
||||||
* Sign up with email and password
|
const login = (email, password) => signInWithEmailAndPassword(auth, email, password);
|
||||||
*/
|
const logout = () => signOut(auth);
|
||||||
async function signup(email, password) {
|
|
||||||
try {
|
const sendEmailVerification = () => {
|
||||||
setError(null);
|
if (auth.currentUser) {
|
||||||
const result = await createUserWithEmailAndPassword(
|
return firebaseSendEmailVerification(auth.currentUser);
|
||||||
auth,
|
|
||||||
email,
|
|
||||||
password,
|
|
||||||
);
|
|
||||||
return result.user;
|
|
||||||
} catch (err) {
|
|
||||||
setError(err.message);
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
const reloadUser = async () => {
|
||||||
* Log in with email and password
|
if (auth.currentUser) {
|
||||||
*/
|
await auth.currentUser.reload();
|
||||||
async function login(email, password) {
|
return auth.currentUser;
|
||||||
try {
|
|
||||||
setError(null);
|
|
||||||
const result = await signInWithEmailAndPassword(
|
|
||||||
auth,
|
|
||||||
email,
|
|
||||||
password,
|
|
||||||
);
|
|
||||||
return result.user;
|
|
||||||
} catch (err) {
|
|
||||||
setError(err.message);
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
const value = {
|
||||||
* Log out current user
|
...state,
|
||||||
*/
|
signup,
|
||||||
async function logout() {
|
login,
|
||||||
try {
|
logout,
|
||||||
setError(null);
|
sendEmailVerification,
|
||||||
await signOut(auth);
|
reloadUser,
|
||||||
} catch (err) {
|
refreshAccount: authStore.refreshAccount,
|
||||||
setError(err.message);
|
};
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
return (
|
||||||
* Listen for auth state changes
|
<AuthContext.Provider value={value}>
|
||||||
*/
|
{!state.loading && children}
|
||||||
useEffect(() => {
|
</AuthContext.Provider>
|
||||||
const unsubscribe = onAuthStateChanged(auth, (user) => {
|
);
|
||||||
setCurrentUser(user);
|
|
||||||
setLoading(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Cleanup subscription
|
|
||||||
return unsubscribe;
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const value = {
|
|
||||||
currentUser,
|
|
||||||
signup,
|
|
||||||
login,
|
|
||||||
logout,
|
|
||||||
error,
|
|
||||||
loading,
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<AuthContext.Provider value={value}>
|
|
||||||
{!loading && children}
|
|
||||||
</AuthContext.Provider>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function useAuth() {
|
||||||
|
const context = useContext(AuthContext);
|
||||||
|
if (context === undefined) {
|
||||||
|
throw new Error("useAuth must be used within an AuthProvider");
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default useAuth;
|
||||||
|
|||||||
44
frontend/src/contexts/UserPropertiesContext.jsx
Normal file
44
frontend/src/contexts/UserPropertiesContext.jsx
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import { createContext, useReducer, useContext } from "react";
|
||||||
|
|
||||||
|
const initialState = {
|
||||||
|
email: "",
|
||||||
|
password: "",
|
||||||
|
};
|
||||||
|
|
||||||
|
const ACTION_TYPES = {
|
||||||
|
SET_USER_PROPERTIES: "SET_USER_PROPERTIES",
|
||||||
|
CLEAR_USER_PROPERTIES: "CLEAR_USER_PROPERTIES",
|
||||||
|
};
|
||||||
|
|
||||||
|
function userPropertiesReducer(state, action) {
|
||||||
|
switch (action.type) {
|
||||||
|
case ACTION_TYPES.SET_USER_PROPERTIES:
|
||||||
|
return { ...state, ...action.payload };
|
||||||
|
case ACTION_TYPES.CLEAR_USER_PROPERTIES:
|
||||||
|
return { ...initialState };
|
||||||
|
default:
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const UserPropertiesContext = createContext(undefined);
|
||||||
|
|
||||||
|
export function UserPropertiesProvider({ children }) {
|
||||||
|
const [state, dispatch] = useReducer(userPropertiesReducer, initialState);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<UserPropertiesContext.Provider value={{ state, dispatch, ACTION_TYPES }}>
|
||||||
|
{children}
|
||||||
|
</UserPropertiesContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useUserProperties() {
|
||||||
|
const context = useContext(UserPropertiesContext);
|
||||||
|
if (context === undefined) {
|
||||||
|
throw new Error(
|
||||||
|
"useUserProperties must be used within a UserPropertiesProvider",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
}
|
||||||
@@ -78,6 +78,18 @@ button {
|
|||||||
font-weight: 200;
|
font-weight: 200;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── App shell (navbar + main + footer) ───────────────────── */
|
||||||
|
|
||||||
|
.app-layout {
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-layout__main {
|
||||||
|
flex: 1 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Page Layouts ─────────────────────────────────────────── */
|
/* ── Page Layouts ─────────────────────────────────────────── */
|
||||||
|
|
||||||
.page {
|
.page {
|
||||||
@@ -397,6 +409,14 @@ button {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Tier-locked: greyed out, non-interactive overlay for features above the user's plan */
|
||||||
|
.tier-locked {
|
||||||
|
opacity: 0.45;
|
||||||
|
pointer-events: none;
|
||||||
|
filter: grayscale(40%);
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
/* Mobile */
|
/* Mobile */
|
||||||
@media (max-width: 480px) {
|
@media (max-width: 480px) {
|
||||||
html {
|
html {
|
||||||
@@ -404,6 +424,8 @@ button {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.page {
|
.page {
|
||||||
|
background-color: var(--color-bg);
|
||||||
|
height: 920px;
|
||||||
padding: 1rem 0.75rem;
|
padding: 1rem 0.75rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -414,7 +436,7 @@ button {
|
|||||||
|
|
||||||
.btn {
|
.btn {
|
||||||
padding: 0.45rem 0.85rem;
|
padding: 0.45rem 0.85rem;
|
||||||
font-size: 0.95rem;
|
font-size: 1.45rem !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn--lg {
|
.btn--lg {
|
||||||
@@ -425,4 +447,8 @@ button {
|
|||||||
.section {
|
.section {
|
||||||
padding: 0.75rem;
|
padding: 0.75rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.mb-lg {
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -2,15 +2,18 @@ import React from "react";
|
|||||||
import ReactDOM from "react-dom/client";
|
import ReactDOM from "react-dom/client";
|
||||||
import { BrowserRouter } from "react-router-dom";
|
import { BrowserRouter } from "react-router-dom";
|
||||||
import { AuthProvider } from "./contexts/AuthContext";
|
import { AuthProvider } from "./contexts/AuthContext";
|
||||||
|
import { UserPropertiesProvider } from "./contexts/UserPropertiesContext";
|
||||||
import App from "./App";
|
import App from "./App";
|
||||||
import "./index.css";
|
import "./index.css";
|
||||||
|
|
||||||
ReactDOM.createRoot(document.getElementById("root")).render(
|
ReactDOM.createRoot(document.getElementById("root")).render(
|
||||||
<React.StrictMode>
|
<React.StrictMode>
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
<AuthProvider>
|
<UserPropertiesProvider>
|
||||||
<App />
|
<AuthProvider>
|
||||||
</AuthProvider>
|
<App />
|
||||||
|
</AuthProvider>
|
||||||
|
</UserPropertiesProvider>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
</React.StrictMode>,
|
</React.StrictMode>,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -33,8 +33,9 @@
|
|||||||
|
|
||||||
@media (max-width: 480px) {
|
@media (max-width: 480px) {
|
||||||
.address__remove {
|
.address__remove {
|
||||||
margin-left: 0.5rem;
|
padding: 0rem 0.9rem;
|
||||||
padding: 0.25rem 0.5rem;
|
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
|
margin-left: -3rem;
|
||||||
|
margin-bottom: 2rem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,15 +1,22 @@
|
|||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
|
import { useAuth } from "../../contexts/AuthContext";
|
||||||
import AddressForm from "../../components/AddressForm";
|
import AddressForm from "../../components/AddressForm";
|
||||||
|
import UpgradeBanner from "../../components/UpgradeBanner";
|
||||||
import { getAddresses, createAddress, deleteAddress, updateAddress } from "../../api/addresses";
|
import { getAddresses, createAddress, deleteAddress, updateAddress } from "../../api/addresses";
|
||||||
import "./Addresses.css";
|
import "./Addresses.css";
|
||||||
|
|
||||||
export default function Addresses() {
|
export default function Addresses() {
|
||||||
|
const { tierLimits, refreshAccount } = useAuth();
|
||||||
const [addresses, setAddresses] = useState([]);
|
const [addresses, setAddresses] = useState([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState(null);
|
const [error, setError] = useState(null);
|
||||||
const [editingId, setEditingId] = useState(null);
|
const [editingId, setEditingId] = useState(null);
|
||||||
const [editLabel, setEditLabel] = useState("");
|
const [editLabel, setEditLabel] = useState("");
|
||||||
|
|
||||||
|
const maxAddresses = tierLimits.max_addresses;
|
||||||
|
const isUnlimited = maxAddresses === -1;
|
||||||
|
const atLimit = !isUnlimited && addresses.length >= maxAddresses;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
async function fetchAddresses() {
|
async function fetchAddresses() {
|
||||||
try {
|
try {
|
||||||
@@ -32,6 +39,7 @@ export default function Addresses() {
|
|||||||
const newAddress = await createAddress(data);
|
const newAddress = await createAddress(data);
|
||||||
setAddresses((prev) => [...prev, newAddress]);
|
setAddresses((prev) => [...prev, newAddress]);
|
||||||
setError(null);
|
setError(null);
|
||||||
|
refreshAccount();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err.message);
|
setError(err.message);
|
||||||
console.error("Failed to create address:", err);
|
console.error("Failed to create address:", err);
|
||||||
@@ -47,6 +55,7 @@ export default function Addresses() {
|
|||||||
await deleteAddress(id);
|
await deleteAddress(id);
|
||||||
setAddresses((prev) => prev.filter((a) => a.id !== id));
|
setAddresses((prev) => prev.filter((a) => a.id !== id));
|
||||||
setError(null);
|
setError(null);
|
||||||
|
refreshAccount();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err.message);
|
setError(err.message);
|
||||||
console.error("Failed to delete address:", err);
|
console.error("Failed to delete address:", err);
|
||||||
@@ -80,7 +89,13 @@ export default function Addresses() {
|
|||||||
<div className="page">
|
<div className="page">
|
||||||
<h1>Add Addresses to Track</h1>
|
<h1>Add Addresses to Track</h1>
|
||||||
|
|
||||||
<div className="mb-xl">
|
{atLimit && (
|
||||||
|
<UpgradeBanner
|
||||||
|
message={`Your plan allows ${maxAddresses} address${maxAddresses !== 1 ? "es" : ""}. Upgrade to track more.`}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className={`mb-xl${atLimit ? " tier-locked" : ""}`}>
|
||||||
<AddressForm onSubmit={handleAddressSubmit} />
|
<AddressForm onSubmit={handleAddressSubmit} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -51,6 +51,69 @@
|
|||||||
opacity: 0.6;
|
opacity: 0.6;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.alerts__accordion-toggle {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-top: 0.75rem;
|
||||||
|
padding: 0.5rem 0 0;
|
||||||
|
border-top: 1px solid var(--color-border);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
background: none;
|
||||||
|
border-left: none;
|
||||||
|
border-right: none;
|
||||||
|
border-bottom: none;
|
||||||
|
width: 100%;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alerts__accordion-toggle:hover {
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.alerts__accordion-chevron {
|
||||||
|
display: inline-block;
|
||||||
|
transition: transform 0.2s ease;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alerts__accordion-chevron--open {
|
||||||
|
transform: rotate(90deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.alerts__accordion-panel {
|
||||||
|
overflow: hidden;
|
||||||
|
max-height: 0;
|
||||||
|
opacity: 0;
|
||||||
|
transition: max-height 0.25s ease, opacity 0.2s ease, padding 0.25s ease;
|
||||||
|
padding: 0 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alerts__accordion-panel--open {
|
||||||
|
max-height: 200px;
|
||||||
|
opacity: 1;
|
||||||
|
padding: 0.75rem 0.25rem 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alerts__threshold-inputs {
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
align-items: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alerts__threshold-inputs .form-field {
|
||||||
|
flex: 1;
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alerts__threshold-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
.alerts__email-buttons {
|
.alerts__email-buttons {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
@@ -62,6 +125,20 @@
|
|||||||
margin-top: 1rem;
|
margin-top: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Locked / disabled notification channel */
|
||||||
|
.alerts__channel-locked {
|
||||||
|
opacity: 0.55;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alerts__channel-locked .upgrade-banner {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alerts__channel-locked input {
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Alerts Page Responsive ──────────────────────────────── */
|
/* ── Alerts Page Responsive ──────────────────────────────── */
|
||||||
|
|
||||||
/* Laptop: tighten the gap */
|
/* Laptop: tighten the gap */
|
||||||
|
|||||||
@@ -1,12 +1,15 @@
|
|||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
|
import { useAuth } from "../../contexts/AuthContext";
|
||||||
import AlertForm from "../../components/AlertForm";
|
import AlertForm from "../../components/AlertForm";
|
||||||
import Button from "../../components/Button";
|
import Button from "../../components/Button";
|
||||||
import Input from "../../components/Input";
|
import Input from "../../components/Input";
|
||||||
|
import UpgradeBanner from "../../components/UpgradeBanner";
|
||||||
import { getAddresses } from "../../api/addresses";
|
import { getAddresses } from "../../api/addresses";
|
||||||
import {
|
import {
|
||||||
getAlerts,
|
getAlerts,
|
||||||
createAlert,
|
createAlert,
|
||||||
updateAlertStatus,
|
updateAlertStatus,
|
||||||
|
updateAlertThresholds,
|
||||||
deleteAlert,
|
deleteAlert,
|
||||||
} from "../../api/alerts";
|
} from "../../api/alerts";
|
||||||
import {
|
import {
|
||||||
@@ -19,6 +22,8 @@ import {
|
|||||||
import "./Alerts.css";
|
import "./Alerts.css";
|
||||||
|
|
||||||
export default function Alerts() {
|
export default function Alerts() {
|
||||||
|
const { tierLimits, userTier } = useAuth();
|
||||||
|
|
||||||
const [addresses, setAddresses] = useState([]);
|
const [addresses, setAddresses] = useState([]);
|
||||||
const [selectedAddressId, setSelectedAddressId] = useState(null);
|
const [selectedAddressId, setSelectedAddressId] = useState(null);
|
||||||
const [alerts, setAlerts] = useState([]);
|
const [alerts, setAlerts] = useState([]);
|
||||||
@@ -39,6 +44,79 @@ export default function Alerts() {
|
|||||||
const [sendingDigest, setSendingDigest] = useState(false);
|
const [sendingDigest, setSendingDigest] = useState(false);
|
||||||
const [hasExistingConfig, setHasExistingConfig] = useState(false);
|
const [hasExistingConfig, setHasExistingConfig] = useState(false);
|
||||||
|
|
||||||
|
const [openAccordions, setOpenAccordions] = useState({});
|
||||||
|
const [thresholdEdits, setThresholdEdits] = useState({});
|
||||||
|
|
||||||
|
const allowedChannels = tierLimits.allowed_channels || ["email"];
|
||||||
|
const canTelegram = allowedChannels.includes("telegram");
|
||||||
|
const canDiscord = allowedChannels.includes("discord");
|
||||||
|
const canSlack = allowedChannels.includes("slack");
|
||||||
|
|
||||||
|
const maxAlertTypes = tierLimits.max_alert_types;
|
||||||
|
const isUnlimitedAlerts = maxAlertTypes === -1;
|
||||||
|
|
||||||
|
function distinctAlertTypeCount() {
|
||||||
|
const types = new Set(alerts.map((a) => a.type));
|
||||||
|
return types.size;
|
||||||
|
}
|
||||||
|
|
||||||
|
const atAlertLimit = !isUnlimitedAlerts && distinctAlertTypeCount() >= maxAlertTypes;
|
||||||
|
|
||||||
|
function toggleAccordion(alertId) {
|
||||||
|
setOpenAccordions((prev) => ({ ...prev, [alertId]: !prev[alertId] }));
|
||||||
|
}
|
||||||
|
|
||||||
|
function getThresholdEdit(alert) {
|
||||||
|
if (thresholdEdits[alert.id]) return thresholdEdits[alert.id];
|
||||||
|
return {
|
||||||
|
minimum: alert.minimum != null ? String(alert.minimum) : "",
|
||||||
|
maximum: alert.maximum != null ? String(alert.maximum) : "",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function setThresholdEdit(alertId, field, value) {
|
||||||
|
setThresholdEdits((prev) => {
|
||||||
|
const current = prev[alertId] || getThresholdEditForAlert(alertId);
|
||||||
|
return { ...prev, [alertId]: { ...current, [field]: value } };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function getThresholdEditForAlert(alertId) {
|
||||||
|
const alert = alerts.find((a) => a.id === alertId);
|
||||||
|
return {
|
||||||
|
minimum: alert?.minimum != null ? String(alert.minimum) : "",
|
||||||
|
maximum: alert?.maximum != null ? String(alert.maximum) : "",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSaveThresholds(alertId) {
|
||||||
|
const edit = thresholdEdits[alertId];
|
||||||
|
if (!edit) return;
|
||||||
|
|
||||||
|
const min = edit.minimum.trim() === "" ? null : Number(edit.minimum);
|
||||||
|
const max = edit.maximum.trim() === "" ? null : Number(edit.maximum);
|
||||||
|
|
||||||
|
if (min !== null && isNaN(min)) return;
|
||||||
|
if (max !== null && isNaN(max)) return;
|
||||||
|
if (min !== null && max !== null && min > max) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const updated = await updateAlertThresholds(alertId, min, max);
|
||||||
|
setAlerts((prev) =>
|
||||||
|
prev.map((a) => (a.id === alertId ? updated : a)),
|
||||||
|
);
|
||||||
|
setThresholdEdits((prev) => {
|
||||||
|
const next = { ...prev };
|
||||||
|
delete next[alertId];
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
setError(null);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message);
|
||||||
|
console.error("Failed to update thresholds:", err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
async function fetchData() {
|
async function fetchData() {
|
||||||
try {
|
try {
|
||||||
@@ -150,11 +228,11 @@ export default function Alerts() {
|
|||||||
|
|
||||||
const config = {
|
const config = {
|
||||||
notification_enabled: notificationEnabled,
|
notification_enabled: notificationEnabled,
|
||||||
discord_webhook_url: discordWebhookUrl || null,
|
discord_webhook_url: canDiscord ? (discordWebhookUrl || null) : null,
|
||||||
telegram_bot_token: telegramBotToken || null,
|
telegram_bot_token: canTelegram ? (telegramBotToken || null) : null,
|
||||||
telegram_chat_id: telegramChatId || null,
|
telegram_chat_id: canTelegram ? (telegramChatId || null) : null,
|
||||||
email: email || null,
|
email: email || null,
|
||||||
slack_webhook_url: slackWebhookUrl || null,
|
slack_webhook_url: canSlack ? (slackWebhookUrl || null) : null,
|
||||||
};
|
};
|
||||||
|
|
||||||
await updateNotificationConfig(config);
|
await updateNotificationConfig(config);
|
||||||
@@ -298,7 +376,13 @@ export default function Alerts() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mb-xl">
|
{atAlertLimit && userTier !== "pro" && (
|
||||||
|
<UpgradeBanner
|
||||||
|
message={`Your ${userTier} plan allows ${maxAlertTypes} alert type${maxAlertTypes !== 1 ? "s" : ""} per address. Upgrade for more.`}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className={`mb-xl${atAlertLimit ? " tier-locked" : ""}`}>
|
||||||
<h3>Create New Alert</h3>
|
<h3>Create New Alert</h3>
|
||||||
<AlertForm onSubmit={handleAlertSubmit} />
|
<AlertForm onSubmit={handleAlertSubmit} />
|
||||||
</div>
|
</div>
|
||||||
@@ -314,42 +398,132 @@ export default function Alerts() {
|
|||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<ul className="list-unstyled">
|
<ul className="list-unstyled">
|
||||||
{alerts.map((alert) => (
|
{alerts.map((alert) => {
|
||||||
<li
|
const hasAccordion =
|
||||||
key={alert.id}
|
alert.type === "incoming_tx" ||
|
||||||
className={`alerts__rule ${!alert.enabled ? "alerts__rule--disabled" : ""}`}
|
alert.type === "outgoing_tx";
|
||||||
>
|
const isOpen = !!openAccordions[alert.id];
|
||||||
<div className="flex flex--between">
|
const edit = getThresholdEdit(alert);
|
||||||
<div>
|
|
||||||
<div className="text-bold mb-sm">
|
return (
|
||||||
{formatAlertType(alert.type)}
|
<li
|
||||||
</div>
|
key={alert.id}
|
||||||
{alert.threshold && (
|
className={`alerts__rule ${!alert.enabled ? "alerts__rule--disabled" : ""}`}
|
||||||
<div className="text-sm text-muted">
|
>
|
||||||
Threshold: {alert.threshold} ETH
|
<div className="flex flex--between">
|
||||||
|
<div>
|
||||||
|
<div className="text-bold mb-sm">
|
||||||
|
{formatAlertType(alert.type)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
{alert.threshold && (
|
||||||
<div className="text-xs text-dimmed">
|
<div className="text-sm text-muted">
|
||||||
Status: {alert.enabled ? "Enabled" : "Disabled"}
|
Threshold: {alert.threshold} ETH
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{alert.minimum != null && (
|
||||||
|
<div className="text-sm text-muted">
|
||||||
|
Min: {alert.minimum} ETH
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{alert.maximum != null && (
|
||||||
|
<div className="text-sm text-muted">
|
||||||
|
Max: {alert.maximum} ETH
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="text-xs text-dimmed">
|
||||||
|
Status:{" "}
|
||||||
|
{alert.enabled ? "Enabled" : "Disabled"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex--center gap-sm">
|
||||||
|
<Button
|
||||||
|
onClick={() =>
|
||||||
|
handleToggleAlert(
|
||||||
|
alert.id,
|
||||||
|
alert.enabled,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{alert.enabled ? "Disable" : "Enable"}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() =>
|
||||||
|
handleDeleteAlert(alert.id)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex--center gap-sm">
|
|
||||||
<Button
|
{hasAccordion && (
|
||||||
onClick={() =>
|
<>
|
||||||
handleToggleAlert(alert.id, alert.enabled)
|
<button
|
||||||
}
|
type="button"
|
||||||
>
|
className="alerts__accordion-toggle"
|
||||||
{alert.enabled ? "Disable" : "Enable"}
|
onClick={() =>
|
||||||
</Button>
|
toggleAccordion(alert.id)
|
||||||
<Button
|
}
|
||||||
onClick={() => handleDeleteAlert(alert.id)}
|
>
|
||||||
>
|
<span
|
||||||
Delete
|
className={`alerts__accordion-chevron ${isOpen ? "alerts__accordion-chevron--open" : ""}`}
|
||||||
</Button>
|
>
|
||||||
</div>
|
▶
|
||||||
</div>
|
</span>
|
||||||
</li>
|
Add optional minimum and maximum
|
||||||
))}
|
threshold values
|
||||||
|
</button>
|
||||||
|
<div
|
||||||
|
className={`alerts__accordion-panel ${isOpen ? "alerts__accordion-panel--open" : ""}`}
|
||||||
|
>
|
||||||
|
<div className="alerts__threshold-inputs">
|
||||||
|
<Input
|
||||||
|
label="Minimum"
|
||||||
|
type="number"
|
||||||
|
step="0.000001"
|
||||||
|
min="0"
|
||||||
|
value={edit.minimum}
|
||||||
|
onChange={(v) =>
|
||||||
|
setThresholdEdit(
|
||||||
|
alert.id,
|
||||||
|
"minimum",
|
||||||
|
v,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
placeholder="No minimum"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="Maximum"
|
||||||
|
type="number"
|
||||||
|
step="0.000001"
|
||||||
|
min="0"
|
||||||
|
value={edit.maximum}
|
||||||
|
onChange={(v) =>
|
||||||
|
setThresholdEdit(
|
||||||
|
alert.id,
|
||||||
|
"maximum",
|
||||||
|
v,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
placeholder="No maximum"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="alerts__threshold-actions">
|
||||||
|
<Button
|
||||||
|
onClick={() =>
|
||||||
|
handleSaveThresholds(alert.id)
|
||||||
|
}
|
||||||
|
className="btn--sm"
|
||||||
|
>
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</ul>
|
</ul>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -391,19 +565,24 @@ export default function Alerts() {
|
|||||||
{notificationEnabled && (
|
{notificationEnabled && (
|
||||||
<>
|
<>
|
||||||
{/* Telegram */}
|
{/* Telegram */}
|
||||||
<div className="section">
|
<div className={`section${!canTelegram ? " alerts__channel-locked" : ""}`}>
|
||||||
<h3 className="mt-0 mb-md">Telegram</h3>
|
<h3 className="mt-0 mb-md">Telegram</h3>
|
||||||
|
{!canTelegram && (
|
||||||
|
<UpgradeBanner message="Upgrade to Premium to enable Telegram alerts" />
|
||||||
|
)}
|
||||||
<Input
|
<Input
|
||||||
label="Bot Token"
|
label="Bot Token"
|
||||||
value={telegramBotToken}
|
value={telegramBotToken}
|
||||||
onChange={setTelegramBotToken}
|
onChange={setTelegramBotToken}
|
||||||
placeholder="123456789:ABCdefGHIjklMNOpqrSTUvwxYZ"
|
placeholder="123456789:ABCdefGHIjklMNOpqrSTUvwxYZ"
|
||||||
|
disabled={!canTelegram}
|
||||||
/>
|
/>
|
||||||
<Input
|
<Input
|
||||||
label="Chat ID"
|
label="Chat ID"
|
||||||
value={telegramChatId}
|
value={telegramChatId}
|
||||||
onChange={setTelegramChatId}
|
onChange={setTelegramChatId}
|
||||||
placeholder="-1001234567890"
|
placeholder="-1001234567890"
|
||||||
|
disabled={!canTelegram}
|
||||||
/>
|
/>
|
||||||
<a
|
<a
|
||||||
href="https://core.telegram.org/bots#how-do-i-create-a-bot"
|
href="https://core.telegram.org/bots#how-do-i-create-a-bot"
|
||||||
@@ -448,13 +627,17 @@ export default function Alerts() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Discord */}
|
{/* Discord */}
|
||||||
<div className="section">
|
<div className={`section${!canDiscord ? " alerts__channel-locked" : ""}`}>
|
||||||
<h3 className="mt-0 mb-md">Discord</h3>
|
<h3 className="mt-0 mb-md">Discord</h3>
|
||||||
|
{!canDiscord && (
|
||||||
|
<UpgradeBanner message="Upgrade to Premium to enable Discord alerts" />
|
||||||
|
)}
|
||||||
<Input
|
<Input
|
||||||
label="Discord Webhook URL"
|
label="Discord Webhook URL"
|
||||||
value={discordWebhookUrl}
|
value={discordWebhookUrl}
|
||||||
onChange={setDiscordWebhookUrl}
|
onChange={setDiscordWebhookUrl}
|
||||||
placeholder="https://discord.com/api/webhooks/..."
|
placeholder="https://discord.com/api/webhooks/..."
|
||||||
|
disabled={!canDiscord}
|
||||||
/>
|
/>
|
||||||
<a
|
<a
|
||||||
href="https://support.discord.com/hc/en-us/articles/228383668-Intro-to-Webhooks"
|
href="https://support.discord.com/hc/en-us/articles/228383668-Intro-to-Webhooks"
|
||||||
@@ -467,13 +650,17 @@ export default function Alerts() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Slack */}
|
{/* Slack */}
|
||||||
<div className="section">
|
<div className={`section${!canSlack ? " alerts__channel-locked" : ""}`}>
|
||||||
<h3 className="mt-0 mb-md">Slack</h3>
|
<h3 className="mt-0 mb-md">Slack</h3>
|
||||||
|
{!canSlack && (
|
||||||
|
<UpgradeBanner message="Upgrade to Pro to enable Slack alerts" />
|
||||||
|
)}
|
||||||
<Input
|
<Input
|
||||||
label="Slack Webhook URL"
|
label="Slack Webhook URL"
|
||||||
value={slackWebhookUrl}
|
value={slackWebhookUrl}
|
||||||
onChange={setSlackWebhookUrl}
|
onChange={setSlackWebhookUrl}
|
||||||
placeholder="https://hooks.slack.com/services/..."
|
placeholder="https://hooks.slack.com/services/..."
|
||||||
|
disabled={!canSlack}
|
||||||
/>
|
/>
|
||||||
<a
|
<a
|
||||||
href="https://api.slack.com/messaging/webhooks"
|
href="https://api.slack.com/messaging/webhooks"
|
||||||
|
|||||||
@@ -40,6 +40,7 @@
|
|||||||
text-align: center;
|
text-align: center;
|
||||||
position: relative;
|
position: relative;
|
||||||
top: -20px;
|
top: -20px;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.login-brand {
|
.login-brand {
|
||||||
@@ -89,6 +90,7 @@
|
|||||||
color: #FFFFFF;
|
color: #FFFFFF;
|
||||||
font-size: 1.5rem;
|
font-size: 1.5rem;
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.login-interactive-fadein {
|
.login-interactive-fadein {
|
||||||
@@ -113,4 +115,21 @@
|
|||||||
padding-right: 1rem;
|
padding-right: 1rem;
|
||||||
font-size: 1.25rem;
|
font-size: 1.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.login-span {
|
||||||
|
color: red
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-weight: 400 !important
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-bg-video {
|
||||||
|
opacity: 0.135;
|
||||||
|
left: 47%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-button {
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -42,12 +42,12 @@ export default function Login() {
|
|||||||
playsInline
|
playsInline
|
||||||
className="login-bg-video"
|
className="login-bg-video"
|
||||||
>
|
>
|
||||||
<source src="/koin_spin.mp4" type="video/mp4" />
|
<source src="/koin_exp_2.mp4" type="video/mp4" />
|
||||||
</video>
|
</video>
|
||||||
|
|
||||||
<div className="login-card login-card-fadein">
|
<div className="login-card login-card-fadein">
|
||||||
<h1 className="login-heading">
|
<h1 className="login-heading">
|
||||||
<span className="login-brand">Koin Ping</span> - Login
|
<span className="login-brand">Koin Ping</span><span className="login-span"> - Login</span>
|
||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
<div className="login-interactive-fadein">
|
<div className="login-interactive-fadein">
|
||||||
|
|||||||
24
frontend/src/pages/privacy/Privacy.css
Normal file
24
frontend/src/pages/privacy/Privacy.css
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
.privacy-container {
|
||||||
|
max-width: 800px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 2rem;
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.privacy-container h1 {
|
||||||
|
font-size: 2rem;
|
||||||
|
font-weight: 200;
|
||||||
|
margin-bottom: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.privacy-para {
|
||||||
|
margin-bottom: 1.25rem;
|
||||||
|
font-size: 1.35rem;
|
||||||
|
font-weight: 200;
|
||||||
|
line-height: 1.65;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.privacy-para:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
30
frontend/src/pages/privacy/Privacy.jsx
Normal file
30
frontend/src/pages/privacy/Privacy.jsx
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import "./Privacy.css";
|
||||||
|
|
||||||
|
export default function Privacy() {
|
||||||
|
return (
|
||||||
|
<div className="privacy-container">
|
||||||
|
<h1>Privacy</h1>
|
||||||
|
<p className="privacy-para">
|
||||||
|
Koin Ping is committed to protecting the privacy of our users. Koin Ping
|
||||||
|
does not sell or rent user information and we do not share user
|
||||||
|
information without prior consent except as compelled by law.
|
||||||
|
</p>
|
||||||
|
<p className="privacy-para">
|
||||||
|
Koin Ping collects only enough information from users to enable the
|
||||||
|
functioning of this website and its services. It will never collect
|
||||||
|
extra information about you and uses no tracking, cookies or logging
|
||||||
|
with personal information.
|
||||||
|
</p>
|
||||||
|
<p className="privacy-para">
|
||||||
|
The only information ever stored is your chosen email address and
|
||||||
|
contact information that is provided upon signup. Koin Ping is located
|
||||||
|
within the United States, and will process and store your information in
|
||||||
|
the United States.
|
||||||
|
</p>
|
||||||
|
<p className="privacy-para">
|
||||||
|
If you would like to remove your data, or if you have any questions
|
||||||
|
about how your data is used, please contact us.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
54
frontend/src/pages/subscribe/CheckoutReturn.jsx
Normal file
54
frontend/src/pages/subscribe/CheckoutReturn.jsx
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
import { useState, useRef } from "react";
|
||||||
|
import { useParams, useNavigate } from "react-router-dom";
|
||||||
|
import { verifyCheckoutSession } from "../../api/stripe";
|
||||||
|
import { useAuth } from "../../contexts/AuthContext";
|
||||||
|
import "./Subscribe.css";
|
||||||
|
|
||||||
|
export default function CheckoutReturn() {
|
||||||
|
const { sessionId } = useParams();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { refreshAccount } = useAuth();
|
||||||
|
const [error, setError] = useState(null);
|
||||||
|
const started = useRef(false);
|
||||||
|
|
||||||
|
if (sessionId && !started.current) {
|
||||||
|
started.current = true;
|
||||||
|
verifyCheckoutSession(sessionId)
|
||||||
|
.then(() => refreshAccount())
|
||||||
|
.then(() => navigate("/addresses", { replace: true }))
|
||||||
|
.catch((err) => setError(err.message || "Payment verification failed"));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!sessionId) {
|
||||||
|
return (
|
||||||
|
<div className="subscribe">
|
||||||
|
<div className="subscribe__container">
|
||||||
|
<h1 className="subscribe__title">Koin Ping</h1>
|
||||||
|
<div className="alert alert--error">No session ID found.</div>
|
||||||
|
<p><a href="/subscribe">Back to Subscribe</a></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="subscribe">
|
||||||
|
<div className="subscribe__container">
|
||||||
|
<h1 className="subscribe__title">Koin Ping</h1>
|
||||||
|
<div className="alert alert--error">{error}</div>
|
||||||
|
<p><a href="/subscribe">Try again</a></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="subscribe">
|
||||||
|
<div className="subscribe__container">
|
||||||
|
<h1 className="subscribe__title">Koin Ping</h1>
|
||||||
|
<p>Verifying your payment...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -15,6 +15,10 @@
|
|||||||
padding: 0 1rem;
|
padding: 0 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.subscribe__container--wide {
|
||||||
|
max-width: 920px;
|
||||||
|
}
|
||||||
|
|
||||||
.subscribe__title {
|
.subscribe__title {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
margin-bottom: 2rem;
|
margin-bottom: 2rem;
|
||||||
@@ -107,52 +111,6 @@
|
|||||||
color: #888;
|
color: #888;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Step 5 summary */
|
|
||||||
.subscribe__summary {
|
|
||||||
background-color: #1e2e1e;
|
|
||||||
border: 1px solid #2d5a2d;
|
|
||||||
border-radius: var(--radius-lg);
|
|
||||||
padding: 1rem 1.25rem;
|
|
||||||
margin-bottom: 1.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.subscribe__summary-title {
|
|
||||||
margin: 0 0 0.5rem;
|
|
||||||
color: #90ee90;
|
|
||||||
font-weight: bold;
|
|
||||||
}
|
|
||||||
|
|
||||||
.subscribe__summary-list {
|
|
||||||
margin: 0;
|
|
||||||
padding-left: 1.25rem;
|
|
||||||
color: var(--color-text-label);
|
|
||||||
line-height: 1.8;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Checkbox rows */
|
|
||||||
.checkbox-row {
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.checkbox-row__label {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.6rem;
|
|
||||||
cursor: pointer;
|
|
||||||
color: #ddd;
|
|
||||||
}
|
|
||||||
|
|
||||||
.checkbox-row__input {
|
|
||||||
width: 16px;
|
|
||||||
height: 16px;
|
|
||||||
accent-color: var(--color-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.checkbox-row__nested {
|
|
||||||
margin-top: 0.5rem;
|
|
||||||
margin-left: 1.75rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Footer navigation */
|
/* Footer navigation */
|
||||||
.subscribe__footer {
|
.subscribe__footer {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -163,20 +121,6 @@
|
|||||||
border-top: 1px solid var(--color-border-light);
|
border-top: 1px solid var(--color-border-light);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Test results */
|
|
||||||
.test-result {
|
|
||||||
font-size: 0.9rem;
|
|
||||||
margin-bottom: 0.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.test-result--success {
|
|
||||||
color: #90ee90;
|
|
||||||
}
|
|
||||||
|
|
||||||
.test-result--failure {
|
|
||||||
color: var(--color-error);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Step subtitle */
|
/* Step subtitle */
|
||||||
.subscribe__subtitle {
|
.subscribe__subtitle {
|
||||||
color: #aaa;
|
color: #aaa;
|
||||||
@@ -184,7 +128,24 @@
|
|||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Subscribe card (Step 2) */
|
.subscribe__subtitle strong {
|
||||||
|
color: var(--color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Verify email actions */
|
||||||
|
.subscribe__verify-actions {
|
||||||
|
text-align: center;
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subscribe__verify-sent {
|
||||||
|
color: var(--color-success, #22c55e);
|
||||||
|
text-align: center;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Subscribe card (Step 2 tier cards) */
|
||||||
.subscribe-card {
|
.subscribe-card {
|
||||||
background-color: var(--color-bg-card, #1a1a2e);
|
background-color: var(--color-bg-card, #1a1a2e);
|
||||||
border: 1px solid var(--color-border-light);
|
border: 1px solid var(--color-border-light);
|
||||||
337
frontend/src/pages/subscribe/Subscribe.jsx
Normal file
337
frontend/src/pages/subscribe/Subscribe.jsx
Normal file
@@ -0,0 +1,337 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { useNavigate, useSearchParams } from "react-router-dom";
|
||||||
|
import { useAuth } from "../../contexts/AuthContext";
|
||||||
|
import {
|
||||||
|
createCheckoutSession,
|
||||||
|
activateFreeTier,
|
||||||
|
} from "../../api/stripe";
|
||||||
|
import Input from "../../components/Input";
|
||||||
|
import Button from "../../components/Button";
|
||||||
|
import TierPicker from "../../components/TierPicker";
|
||||||
|
import "./Subscribe.css";
|
||||||
|
|
||||||
|
const STEPS = ["Create Account", "Verify Email", "Choose Plan"];
|
||||||
|
|
||||||
|
export default function Subscribe() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [searchParams] = useSearchParams();
|
||||||
|
const { currentUser, signup, sendEmailVerification, reloadUser, refreshAccount } = useAuth();
|
||||||
|
|
||||||
|
const queryParameters = new URLSearchParams(window.location.search)
|
||||||
|
const success = queryParameters?.get("payment")
|
||||||
|
const session_id = queryParameters?.get("session_id")
|
||||||
|
|
||||||
|
console.log('success, session_id ------>', success, session_id)
|
||||||
|
|
||||||
|
function forward(success, session_id) {
|
||||||
|
success && session_id ?
|
||||||
|
navigate('/addresses') : console.log('ewps')
|
||||||
|
}
|
||||||
|
|
||||||
|
setTimeout(forward, 2000, success, session_id);
|
||||||
|
|
||||||
|
const [step, setStep] = useState(
|
||||||
|
currentUser ? (currentUser.emailVerified ? 3 : 2) : 1
|
||||||
|
);
|
||||||
|
const [verificationSent, setVerificationSent] = useState(false);
|
||||||
|
const [data, setData] = useState({
|
||||||
|
email: currentUser?.email || "",
|
||||||
|
password: "",
|
||||||
|
confirmPassword: "",
|
||||||
|
selectedTier: null,
|
||||||
|
billingInterval: "annual",
|
||||||
|
});
|
||||||
|
const [error, setError] = useState(
|
||||||
|
searchParams.get("payment") === "cancelled"
|
||||||
|
? "Payment was cancelled. Please try again."
|
||||||
|
: ""
|
||||||
|
);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
function set(field, value) {
|
||||||
|
setData((prev) => ({ ...prev, [field]: value }));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleStep1() {
|
||||||
|
setError("");
|
||||||
|
if (!data.email || !data.password) {
|
||||||
|
setError("Email and password are required");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (data.password.length < 6) {
|
||||||
|
setError("Password must be at least 6 characters");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (data.password !== data.confirmPassword) {
|
||||||
|
setError("Passwords do not match");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
await signup(data.email, data.password);
|
||||||
|
await sendEmailVerification();
|
||||||
|
setVerificationSent(true);
|
||||||
|
setStep(2);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message || "Failed to create account");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleStep2() {
|
||||||
|
setError("");
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const user = await reloadUser();
|
||||||
|
if (user?.emailVerified) {
|
||||||
|
setStep(3);
|
||||||
|
} else {
|
||||||
|
setError("Email not yet verified. Please check your inbox and click the verification link.");
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message || "Failed to check verification status");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleResendVerification() {
|
||||||
|
setError("");
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
await sendEmailVerification();
|
||||||
|
setVerificationSent(true);
|
||||||
|
setError("");
|
||||||
|
} catch (err) {
|
||||||
|
if (err.code === "auth/too-many-requests") {
|
||||||
|
setError("Too many requests. Please wait a moment before trying again.");
|
||||||
|
} else {
|
||||||
|
setError(err.message || "Failed to resend verification email");
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleStep3() {
|
||||||
|
setError("");
|
||||||
|
if (!data.selectedTier) {
|
||||||
|
setError("Please select a plan");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
if (data.selectedTier === "free") {
|
||||||
|
await activateFreeTier();
|
||||||
|
await refreshAccount();
|
||||||
|
navigate("/addresses", { replace: true });
|
||||||
|
} else {
|
||||||
|
const { url } = await createCheckoutSession(data.selectedTier, data.billingInterval);
|
||||||
|
window.location.href = url;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message || "Something went wrong");
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function ProgressBar() {
|
||||||
|
return (
|
||||||
|
<div className="progress-bar">
|
||||||
|
{STEPS.map((label, i) => {
|
||||||
|
const stepNum = i + 1;
|
||||||
|
const done = step > stepNum;
|
||||||
|
const active = step === stepNum;
|
||||||
|
const dotClass = done
|
||||||
|
? "progress-bar__dot--done"
|
||||||
|
: active
|
||||||
|
? "progress-bar__dot--active"
|
||||||
|
: "progress-bar__dot--pending";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={label} className="progress-bar__step">
|
||||||
|
{i > 0 && (
|
||||||
|
<div
|
||||||
|
className={`progress-bar__connector ${done || active
|
||||||
|
? "progress-bar__connector--active"
|
||||||
|
: "progress-bar__connector--inactive"
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<div>
|
||||||
|
<div className={`progress-bar__dot ${dotClass}`}>
|
||||||
|
{done ? "\u2713" : stepNum}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className={`progress-bar__label ${active
|
||||||
|
? "progress-bar__label--active"
|
||||||
|
: "progress-bar__label--inactive"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Step content ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function StepCreateAccount() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<h2 className="mb-lg">Create your account</h2>
|
||||||
|
<Input
|
||||||
|
label="Email"
|
||||||
|
type="email"
|
||||||
|
value={data.email}
|
||||||
|
onChange={(v) => set("email", v)}
|
||||||
|
disabled={loading}
|
||||||
|
placeholder="you@example.com"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="Password"
|
||||||
|
type="password"
|
||||||
|
value={data.password}
|
||||||
|
onChange={(v) => set("password", v)}
|
||||||
|
disabled={loading}
|
||||||
|
placeholder="At least 6 characters"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="Confirm Password"
|
||||||
|
type="password"
|
||||||
|
value={data.confirmPassword}
|
||||||
|
onChange={(v) => set("confirmPassword", v)}
|
||||||
|
disabled={loading}
|
||||||
|
placeholder="Repeat your password"
|
||||||
|
className="form-field--last"
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function StepVerifyEmail() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<h2 className="mb-sm">Verify your email</h2>
|
||||||
|
<p className="subscribe__subtitle">
|
||||||
|
We've sent a verification link to <strong>{currentUser?.email}</strong>.
|
||||||
|
Please check your inbox and click the link to verify your email address.
|
||||||
|
</p>
|
||||||
|
{verificationSent && !error && (
|
||||||
|
<p className="subscribe__verify-sent">Verification email sent!</p>
|
||||||
|
)}
|
||||||
|
<div className="subscribe__verify-actions">
|
||||||
|
<Button
|
||||||
|
onClick={handleResendVerification}
|
||||||
|
disabled={loading}
|
||||||
|
variant="ghost"
|
||||||
|
>
|
||||||
|
{loading ? "Sending..." : "Resend verification email"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function StepChoosePlan() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<h2 className="mb-sm">Choose your monitoring plan</h2>
|
||||||
|
<p className="subscribe__subtitle">
|
||||||
|
Select the plan that works best for you. You can upgrade anytime.
|
||||||
|
</p>
|
||||||
|
<TierPicker
|
||||||
|
onSelect={(tier, interval) => {
|
||||||
|
set("selectedTier", tier);
|
||||||
|
set("billingInterval", interval);
|
||||||
|
}}
|
||||||
|
selectedTier={data.selectedTier}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Footer nav ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function Footer() {
|
||||||
|
async function handleNext() {
|
||||||
|
if (step === 1) handleStep1();
|
||||||
|
else if (step === 2) await handleStep2();
|
||||||
|
else if (step === 3) await handleStep3();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleBack() {
|
||||||
|
setError("");
|
||||||
|
setStep((s) => s - 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextLabel =
|
||||||
|
step === 1
|
||||||
|
? "Create Account"
|
||||||
|
: step === 2
|
||||||
|
? "I've verified my email"
|
||||||
|
: !data.selectedTier
|
||||||
|
? "Continue"
|
||||||
|
: data.selectedTier === "free"
|
||||||
|
? "Start Free Trial"
|
||||||
|
: "Subscribe & Continue";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="subscribe__footer">
|
||||||
|
<div>
|
||||||
|
{step > 1 && (
|
||||||
|
<Button onClick={handleBack} disabled={loading} variant="ghost">
|
||||||
|
← Back
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
onClick={handleNext}
|
||||||
|
disabled={loading || (step === 3 && !data.selectedTier)}
|
||||||
|
className="text-bold"
|
||||||
|
>
|
||||||
|
{loading ? "Please wait..." : nextLabel}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Render ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const stepContent = {
|
||||||
|
1: StepCreateAccount(),
|
||||||
|
2: StepVerifyEmail(),
|
||||||
|
3: StepChoosePlan(),
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="subscribe">
|
||||||
|
<div
|
||||||
|
className={`subscribe__container${step === 3 ? " subscribe__container--wide" : ""}`}
|
||||||
|
>
|
||||||
|
<h1 className="subscribe__title">Koin Ping</h1>
|
||||||
|
|
||||||
|
{ProgressBar()}
|
||||||
|
|
||||||
|
{error && <div className="alert alert--error">{error}</div>}
|
||||||
|
|
||||||
|
<div className="subscribe__card">
|
||||||
|
{stepContent[step]}
|
||||||
|
{Footer()}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{step === 1 && (
|
||||||
|
<p className="subscribe__login-link">
|
||||||
|
Already have an account? <a href="/login">Log in here</a>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,679 +0,0 @@
|
|||||||
/**
|
|
||||||
* Subscribe / Subscribe Wizard
|
|
||||||
*
|
|
||||||
* 5-step guided flow: Create Account -> Add Wallet -> Alert Rules -> Notifications -> Done
|
|
||||||
* After account creation, user is redirected to Stripe Checkout for payment.
|
|
||||||
* On successful payment, they return here at step 2 (Add Wallet).
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { useState, useEffect } from "react";
|
|
||||||
import { useNavigate, useSearchParams } from "react-router-dom";
|
|
||||||
import { useAuth } from "../../contexts/AuthContext";
|
|
||||||
import { createAddress, getAddresses } from "../../api/addresses";
|
|
||||||
import { createAlert } from "../../api/alerts";
|
|
||||||
import {
|
|
||||||
updateNotificationConfig,
|
|
||||||
testNotificationChannels,
|
|
||||||
} from "../../api/notificationConfig";
|
|
||||||
import { createCheckoutSession, getSubscriptionStatus, verifyCheckoutSession } from "../../api/stripe";
|
|
||||||
import Input from "../../components/Input";
|
|
||||||
import Button from "../../components/Button";
|
|
||||||
import "./Subscribe.css";
|
|
||||||
|
|
||||||
const STEPS = [
|
|
||||||
"Create Account",
|
|
||||||
"Add Wallet",
|
|
||||||
"Alert Rules",
|
|
||||||
"Notifications",
|
|
||||||
"Done",
|
|
||||||
];
|
|
||||||
|
|
||||||
export default function Subscribe() {
|
|
||||||
const { currentUser, signup } = useAuth();
|
|
||||||
const navigate = useNavigate();
|
|
||||||
const [searchParams, setSearchParams] = useSearchParams();
|
|
||||||
|
|
||||||
const [step, setStep] = useState(1);
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
const [error, setError] = useState("");
|
|
||||||
const [skipWarning, setSkipWarning] = useState("");
|
|
||||||
const [testResults, setTestResults] = useState(null);
|
|
||||||
const [testLoading, setTestLoading] = useState(false);
|
|
||||||
|
|
||||||
const [data, setData] = useState({
|
|
||||||
email: "",
|
|
||||||
password: "",
|
|
||||||
confirmPassword: "",
|
|
||||||
walletAddress: "",
|
|
||||||
walletLabel: "",
|
|
||||||
createdAddressId: null,
|
|
||||||
alertIncomingTx: false,
|
|
||||||
alertOutgoingTx: false,
|
|
||||||
alertLargeTransfer: false,
|
|
||||||
largeTransferThreshold: "",
|
|
||||||
alertBalanceBelow: false,
|
|
||||||
balanceBelowThreshold: "",
|
|
||||||
discordWebhookUrl: "",
|
|
||||||
slackWebhookUrl: "",
|
|
||||||
notificationEmail: "",
|
|
||||||
alertsCreated: [],
|
|
||||||
notificationConfigured: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
function set(field, value) {
|
|
||||||
setData((prev) => ({ ...prev, [field]: value }));
|
|
||||||
}
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!currentUser) return;
|
|
||||||
getAddresses()
|
|
||||||
.then((addresses) => {
|
|
||||||
if (addresses.length > 0) {
|
|
||||||
navigate("/addresses", { replace: true });
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch(() => { });
|
|
||||||
}, [currentUser, navigate]);
|
|
||||||
|
|
||||||
// Handle Stripe redirect back from checkout
|
|
||||||
useEffect(() => {
|
|
||||||
if (!currentUser) return;
|
|
||||||
const payment = searchParams.get("payment");
|
|
||||||
const sessionId = searchParams.get("session_id");
|
|
||||||
if (payment === "success" && sessionId) {
|
|
||||||
setSearchParams({}, { replace: true });
|
|
||||||
setLoading(true);
|
|
||||||
verifyCheckoutSession(sessionId)
|
|
||||||
.then(() => {
|
|
||||||
setStep(2);
|
|
||||||
})
|
|
||||||
.catch((err) => {
|
|
||||||
setError("Payment verification failed: " + err.message);
|
|
||||||
setStep(1);
|
|
||||||
})
|
|
||||||
.finally(() => setLoading(false));
|
|
||||||
} else if (payment === "cancelled") {
|
|
||||||
setSearchParams({}, { replace: true });
|
|
||||||
setStep(1);
|
|
||||||
setError("Payment was cancelled. Please try again.");
|
|
||||||
}
|
|
||||||
}, [currentUser, searchParams, setSearchParams]);
|
|
||||||
|
|
||||||
// ── Step handlers ─────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
async function handleStep1() {
|
|
||||||
setError("");
|
|
||||||
if (!data.email || !data.password || !data.confirmPassword) {
|
|
||||||
setError("Please fill in all fields");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (data.password !== data.confirmPassword) {
|
|
||||||
setError("Passwords do not match");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (data.password.length < 6) {
|
|
||||||
setError("Password must be at least 6 characters");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
setLoading(true);
|
|
||||||
if (!currentUser) {
|
|
||||||
await signup(data.email, data.password);
|
|
||||||
}
|
|
||||||
const status = await getSubscriptionStatus();
|
|
||||||
if (status.subscription_status === "active" || status.subscription_status === "trialing") {
|
|
||||||
setStep(2);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const { url } = await createCheckoutSession();
|
|
||||||
window.location.href = url;
|
|
||||||
} catch (err) {
|
|
||||||
if (err.code === "auth/email-already-in-use") {
|
|
||||||
setError("Email already in use. Try logging in instead.");
|
|
||||||
} else if (err.code === "auth/invalid-email") {
|
|
||||||
setError("Invalid email address");
|
|
||||||
} else if (err.code === "auth/weak-password") {
|
|
||||||
setError("Password is too weak");
|
|
||||||
} else {
|
|
||||||
setError("Failed to create account: " + err.message);
|
|
||||||
}
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleStep2() {
|
|
||||||
setError("");
|
|
||||||
if (!data.walletAddress) {
|
|
||||||
setError("Please enter a wallet address");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!/^0x[0-9a-fA-F]{40}$/.test(data.walletAddress)) {
|
|
||||||
setError("Invalid ETH address (must be 0x followed by 40 hex characters)");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
setLoading(true);
|
|
||||||
const created = await createAddress({
|
|
||||||
address: data.walletAddress,
|
|
||||||
label: data.walletLabel || undefined,
|
|
||||||
});
|
|
||||||
set("createdAddressId", created.id);
|
|
||||||
setStep(3);
|
|
||||||
} catch (err) {
|
|
||||||
setError(err.message);
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleStep3() {
|
|
||||||
setError("");
|
|
||||||
const rules = [];
|
|
||||||
if (data.alertIncomingTx) rules.push({ type: "incoming_tx" });
|
|
||||||
if (data.alertOutgoingTx) rules.push({ type: "outgoing_tx" });
|
|
||||||
if (data.alertLargeTransfer) {
|
|
||||||
if (!data.largeTransferThreshold) {
|
|
||||||
setError("Please enter a threshold for large transfers");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
rules.push({ type: "large_transfer", threshold: data.largeTransferThreshold });
|
|
||||||
}
|
|
||||||
if (data.alertBalanceBelow) {
|
|
||||||
if (!data.balanceBelowThreshold) {
|
|
||||||
setError("Please enter a threshold for balance below");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
rules.push({ type: "balance_below", threshold: data.balanceBelowThreshold });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (rules.length === 0) {
|
|
||||||
setStep(4);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
setLoading(true);
|
|
||||||
const created = [];
|
|
||||||
for (const rule of rules) {
|
|
||||||
const result = await createAlert(data.createdAddressId, rule);
|
|
||||||
created.push(result);
|
|
||||||
}
|
|
||||||
set("alertsCreated", created);
|
|
||||||
setStep(4);
|
|
||||||
} catch (err) {
|
|
||||||
setError(err.message);
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleStep4() {
|
|
||||||
setError("");
|
|
||||||
const hasAny =
|
|
||||||
data.discordWebhookUrl || data.slackWebhookUrl || data.notificationEmail;
|
|
||||||
if (!hasAny) {
|
|
||||||
setStep(5);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
setLoading(true);
|
|
||||||
await updateNotificationConfig({
|
|
||||||
notification_enabled: true,
|
|
||||||
discord_webhook_url: data.discordWebhookUrl || undefined,
|
|
||||||
slack_webhook_url: data.slackWebhookUrl || undefined,
|
|
||||||
email: data.notificationEmail || undefined,
|
|
||||||
});
|
|
||||||
set("notificationConfigured", true);
|
|
||||||
setStep(5);
|
|
||||||
} catch (err) {
|
|
||||||
setError(err.message);
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleTestChannels() {
|
|
||||||
setTestLoading(true);
|
|
||||||
setTestResults(null);
|
|
||||||
try {
|
|
||||||
const results = await testNotificationChannels();
|
|
||||||
setTestResults(results);
|
|
||||||
} catch (err) {
|
|
||||||
setTestResults({ error: err.message });
|
|
||||||
} finally {
|
|
||||||
setTestLoading(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Progress bar ──────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function ProgressBar() {
|
|
||||||
return (
|
|
||||||
<div className="progress-bar">
|
|
||||||
{STEPS.map((label, i) => {
|
|
||||||
const stepNum = i + 1;
|
|
||||||
const done = step > stepNum;
|
|
||||||
const active = step === stepNum;
|
|
||||||
const dotClass = done
|
|
||||||
? "progress-bar__dot--done"
|
|
||||||
: active
|
|
||||||
? "progress-bar__dot--active"
|
|
||||||
: "progress-bar__dot--pending";
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div key={label} className="progress-bar__step">
|
|
||||||
{i > 0 && (
|
|
||||||
<div
|
|
||||||
className={`progress-bar__connector ${done || active
|
|
||||||
? "progress-bar__connector--active"
|
|
||||||
: "progress-bar__connector--inactive"
|
|
||||||
}`}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<div>
|
|
||||||
<div className={`progress-bar__dot ${dotClass}`}>
|
|
||||||
{done ? "\u2713" : stepNum}
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
className={`progress-bar__label ${active
|
|
||||||
? "progress-bar__label--active"
|
|
||||||
: "progress-bar__label--inactive"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{label}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Step content ──────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function Step1() {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<h2 className="mb-lg">Create your account</h2>
|
|
||||||
<Input
|
|
||||||
label="Email"
|
|
||||||
type="email"
|
|
||||||
value={data.email}
|
|
||||||
onChange={(v) => set("email", v)}
|
|
||||||
disabled={loading}
|
|
||||||
placeholder="you@example.com"
|
|
||||||
/>
|
|
||||||
<Input
|
|
||||||
label="Password"
|
|
||||||
type="password"
|
|
||||||
value={data.password}
|
|
||||||
onChange={(v) => set("password", v)}
|
|
||||||
disabled={loading}
|
|
||||||
placeholder="At least 6 characters"
|
|
||||||
/>
|
|
||||||
<Input
|
|
||||||
label="Confirm Password"
|
|
||||||
type="password"
|
|
||||||
value={data.confirmPassword}
|
|
||||||
onChange={(v) => set("confirmPassword", v)}
|
|
||||||
disabled={loading}
|
|
||||||
placeholder="Repeat your password"
|
|
||||||
className="form-field--last"
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function Step2() {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<h2 className="mb-sm">Add a wallet address</h2>
|
|
||||||
<p className="subscribe__subtitle">
|
|
||||||
Enter the Ethereum address you want to monitor.
|
|
||||||
</p>
|
|
||||||
<Input
|
|
||||||
label="ETH Address"
|
|
||||||
value={data.walletAddress}
|
|
||||||
onChange={(v) => set("walletAddress", v)}
|
|
||||||
disabled={loading}
|
|
||||||
placeholder="0x..."
|
|
||||||
/>
|
|
||||||
<Input
|
|
||||||
label="Label (optional)"
|
|
||||||
value={data.walletLabel}
|
|
||||||
onChange={(v) => set("walletLabel", v)}
|
|
||||||
disabled={loading}
|
|
||||||
placeholder="e.g. My main wallet"
|
|
||||||
className="form-field--last"
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function Step3() {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<h2 className="mb-sm">Configure alert rules</h2>
|
|
||||||
<p className="subscribe__subtitle">
|
|
||||||
Choose which events trigger notifications. You can change these later.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<CheckboxRow
|
|
||||||
checked={data.alertIncomingTx}
|
|
||||||
onChange={(v) => set("alertIncomingTx", v)}
|
|
||||||
label="Incoming transaction"
|
|
||||||
/>
|
|
||||||
<CheckboxRow
|
|
||||||
checked={data.alertOutgoingTx}
|
|
||||||
onChange={(v) => set("alertOutgoingTx", v)}
|
|
||||||
label="Outgoing transaction"
|
|
||||||
/>
|
|
||||||
<CheckboxRow
|
|
||||||
checked={data.alertLargeTransfer}
|
|
||||||
onChange={(v) => set("alertLargeTransfer", v)}
|
|
||||||
label="Large transfer"
|
|
||||||
>
|
|
||||||
{data.alertLargeTransfer && (
|
|
||||||
<div className="checkbox-row__nested">
|
|
||||||
<Input
|
|
||||||
type="number"
|
|
||||||
label=""
|
|
||||||
value={data.largeTransferThreshold}
|
|
||||||
onChange={(v) => set("largeTransferThreshold", v)}
|
|
||||||
placeholder="Threshold (ETH)"
|
|
||||||
min="0"
|
|
||||||
step="0.01"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</CheckboxRow>
|
|
||||||
<CheckboxRow
|
|
||||||
checked={data.alertBalanceBelow}
|
|
||||||
onChange={(v) => set("alertBalanceBelow", v)}
|
|
||||||
label="Balance below"
|
|
||||||
>
|
|
||||||
{data.alertBalanceBelow && (
|
|
||||||
<div className="checkbox-row__nested">
|
|
||||||
<Input
|
|
||||||
type="number"
|
|
||||||
label=""
|
|
||||||
value={data.balanceBelowThreshold}
|
|
||||||
onChange={(v) => set("balanceBelowThreshold", v)}
|
|
||||||
placeholder="Threshold (ETH)"
|
|
||||||
min="0"
|
|
||||||
step="0.01"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</CheckboxRow>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function Step4() {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<h2 className="mb-sm">Set up notifications</h2>
|
|
||||||
<p className="subscribe__subtitle">
|
|
||||||
Add at least one channel so you receive alerts. All fields are optional.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div className="mb-md">
|
|
||||||
<label className="form-label">
|
|
||||||
Discord Webhook URL{" "}
|
|
||||||
<a
|
|
||||||
href="https://support.discord.com/hc/en-us/articles/228383668"
|
|
||||||
target="_blank"
|
|
||||||
rel="noreferrer"
|
|
||||||
className="help-link"
|
|
||||||
>
|
|
||||||
(how to get one)
|
|
||||||
</a>
|
|
||||||
</label>
|
|
||||||
<Input
|
|
||||||
label=""
|
|
||||||
type="url"
|
|
||||||
value={data.discordWebhookUrl}
|
|
||||||
onChange={(v) => set("discordWebhookUrl", v)}
|
|
||||||
disabled={loading}
|
|
||||||
placeholder="https://discord.com/api/webhooks/..."
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mb-md">
|
|
||||||
<label className="form-label">
|
|
||||||
Slack Webhook URL{" "}
|
|
||||||
<a
|
|
||||||
href="https://api.slack.com/messaging/webhooks"
|
|
||||||
target="_blank"
|
|
||||||
rel="noreferrer"
|
|
||||||
className="help-link"
|
|
||||||
>
|
|
||||||
(how to get one)
|
|
||||||
</a>
|
|
||||||
</label>
|
|
||||||
<Input
|
|
||||||
label=""
|
|
||||||
type="url"
|
|
||||||
value={data.slackWebhookUrl}
|
|
||||||
onChange={(v) => set("slackWebhookUrl", v)}
|
|
||||||
disabled={loading}
|
|
||||||
placeholder="https://hooks.slack.com/services/..."
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Input
|
|
||||||
label="Email address for alerts"
|
|
||||||
type="email"
|
|
||||||
value={data.notificationEmail}
|
|
||||||
onChange={(v) => set("notificationEmail", v)}
|
|
||||||
disabled={loading}
|
|
||||||
placeholder="you@example.com"
|
|
||||||
className="form-field--last"
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function Step5() {
|
|
||||||
const alertCount = data.alertsCreated.length;
|
|
||||||
const hasNotif = data.notificationConfigured;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<h2 className="mb-md">You're all set!</h2>
|
|
||||||
|
|
||||||
<div className="subscribe__summary">
|
|
||||||
<p className="subscribe__summary-title">Summary</p>
|
|
||||||
<ul className="subscribe__summary-list">
|
|
||||||
<li>
|
|
||||||
Wallet address added:{" "}
|
|
||||||
<span className="text-mono text-white-sm">
|
|
||||||
{data.walletAddress}
|
|
||||||
</span>
|
|
||||||
{data.walletLabel && ` (${data.walletLabel})`}
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
Alert rules configured:{" "}
|
|
||||||
<span className="text-white">
|
|
||||||
{alertCount > 0 ? `${alertCount} rule${alertCount !== 1 ? "s" : ""}` : "None (skipped)"}
|
|
||||||
</span>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
Notification channels:{" "}
|
|
||||||
<span className="text-white">
|
|
||||||
{hasNotif ? "Configured" : "Not set up (skipped)"}
|
|
||||||
</span>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{hasNotif && (
|
|
||||||
<div className="mb-lg">
|
|
||||||
<Button
|
|
||||||
onClick={handleTestChannels}
|
|
||||||
disabled={testLoading}
|
|
||||||
variant="ghost"
|
|
||||||
>
|
|
||||||
{testLoading ? "Testing..." : "Test All Channels"}
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
{testResults && (
|
|
||||||
<div className="mt-md">
|
|
||||||
{testResults.error ? (
|
|
||||||
<p className="text-error">{testResults.error}</p>
|
|
||||||
) : (
|
|
||||||
<ul className="list-unstyled">
|
|
||||||
{Object.entries(testResults).map(([channel, result]) => (
|
|
||||||
<li
|
|
||||||
key={channel}
|
|
||||||
className={`test-result ${result.success ? "test-result--success" : "test-result--failure"}`}
|
|
||||||
>
|
|
||||||
{result.success ? "\u2713" : "\u2717"} {channel}:{" "}
|
|
||||||
{result.message || (result.success ? "OK" : "Failed")}
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Button onClick={() => navigate("/addresses")} className="btn--lg text-bold">
|
|
||||||
Go to Dashboard →
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Shared helpers ────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function CheckboxRow({ checked, onChange, label, children }) {
|
|
||||||
return (
|
|
||||||
<div className="checkbox-row">
|
|
||||||
<label className="checkbox-row__label">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={checked}
|
|
||||||
onChange={(e) => onChange(e.target.checked)}
|
|
||||||
className="checkbox-row__input"
|
|
||||||
/>
|
|
||||||
{label}
|
|
||||||
</label>
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Footer navigation ─────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function Footer() {
|
|
||||||
if (step === 5) return null;
|
|
||||||
|
|
||||||
const canSkip = step === 3 || step === 4;
|
|
||||||
const canBack = step > 2;
|
|
||||||
|
|
||||||
async function handleNext() {
|
|
||||||
setSkipWarning("");
|
|
||||||
if (step === 1) await handleStep1();
|
|
||||||
else if (step === 2) await handleStep2();
|
|
||||||
else if (step === 3) await handleStep3();
|
|
||||||
else if (step === 4) await handleStep4();
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleSkip() {
|
|
||||||
setError("");
|
|
||||||
setSkipWarning("");
|
|
||||||
setStep((s) => s + 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleBack() {
|
|
||||||
setError("");
|
|
||||||
setSkipWarning("");
|
|
||||||
setStep((s) => s - 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
const nextLabel = step === 1
|
|
||||||
? "Create Account & Subscribe"
|
|
||||||
: step === 4
|
|
||||||
? "Finish"
|
|
||||||
: "Next →";
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="subscribe__footer">
|
|
||||||
<div>
|
|
||||||
{canBack && (
|
|
||||||
<Button
|
|
||||||
onClick={handleBack}
|
|
||||||
disabled={loading}
|
|
||||||
variant="ghost"
|
|
||||||
>
|
|
||||||
← Back
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex gap-md">
|
|
||||||
{canSkip && (
|
|
||||||
<Button
|
|
||||||
onClick={handleSkip}
|
|
||||||
disabled={loading}
|
|
||||||
variant="ghost"
|
|
||||||
>
|
|
||||||
Skip for now
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
<Button
|
|
||||||
onClick={handleNext}
|
|
||||||
disabled={loading}
|
|
||||||
className="text-bold"
|
|
||||||
>
|
|
||||||
{loading ? "Please wait..." : nextLabel}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Render ────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
const stepContent = {
|
|
||||||
1: Step1(),
|
|
||||||
2: Step2(),
|
|
||||||
3: Step3(),
|
|
||||||
4: Step4(),
|
|
||||||
5: Step5(),
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="subscribe">
|
|
||||||
<div className="subscribe__container">
|
|
||||||
<h1 className="subscribe__title">Koin Ping</h1>
|
|
||||||
|
|
||||||
{ProgressBar()}
|
|
||||||
|
|
||||||
{error && (
|
|
||||||
<div className="alert alert--error">{error}</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{skipWarning && (
|
|
||||||
<div className="alert alert--warning">{skipWarning}</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="subscribe__card">
|
|
||||||
{stepContent[step]}
|
|
||||||
{Footer()}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{step === 1 && (
|
|
||||||
<p className="subscribe__login-link">
|
|
||||||
Already have an account?{" "}
|
|
||||||
<a href="/login">Log in here</a>
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
31
frontend/src/pages/support/Support.css
Normal file
31
frontend/src/pages/support/Support.css
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
.support-page__title {
|
||||||
|
font-size: 2rem;
|
||||||
|
font-weight: 200;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.support-page__intro {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
font-weight: 200;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.support-page__alert {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.support-form__description {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.support-form__textarea {
|
||||||
|
min-height: 10rem;
|
||||||
|
resize: vertical;
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.support-form__submit {
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
}
|
||||||
121
frontend/src/pages/support/Support.jsx
Normal file
121
frontend/src/pages/support/Support.jsx
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useAuth } from "../../contexts/AuthContext";
|
||||||
|
import Input from "../../components/Input";
|
||||||
|
import Button from "../../components/Button";
|
||||||
|
import { submitSupportRequest } from "../../api/support";
|
||||||
|
import "./Support.css";
|
||||||
|
|
||||||
|
const EMAIL_MAX = 320;
|
||||||
|
const DESCRIPTION_MAX = 8000;
|
||||||
|
const DESCRIPTION_PATTERN = /^[a-zA-Z0-9\s]+$/;
|
||||||
|
|
||||||
|
function validateEmail(value) {
|
||||||
|
const v = value.trim();
|
||||||
|
if (!v) return "Email is required.";
|
||||||
|
if (v.length > EMAIL_MAX) return "Email is too long.";
|
||||||
|
const ok =
|
||||||
|
/^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)+$/.test(
|
||||||
|
v,
|
||||||
|
);
|
||||||
|
if (!ok) return "Enter a valid email address.";
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateDescription(value) {
|
||||||
|
const t = value.trim();
|
||||||
|
if (!t) return "Description of issue is required.";
|
||||||
|
if (t.length > DESCRIPTION_MAX) return "Description is too long.";
|
||||||
|
if (!DESCRIPTION_PATTERN.test(t)) {
|
||||||
|
return "Use only letters, numbers, and spaces (no special characters).";
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Support() {
|
||||||
|
const { currentUser } = useAuth();
|
||||||
|
const [email, setEmail] = useState("");
|
||||||
|
const [description, setDescription] = useState("");
|
||||||
|
const [error, setError] = useState(null);
|
||||||
|
const [success, setSuccess] = useState(false);
|
||||||
|
const [sending, setSending] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (currentUser?.email) {
|
||||||
|
setEmail(currentUser.email);
|
||||||
|
}
|
||||||
|
}, [currentUser?.email]);
|
||||||
|
|
||||||
|
async function handleSubmit(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
setError(null);
|
||||||
|
setSuccess(false);
|
||||||
|
|
||||||
|
const emailErr = validateEmail(email);
|
||||||
|
if (emailErr) {
|
||||||
|
setError(emailErr);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const descErr = validateDescription(description);
|
||||||
|
if (descErr) {
|
||||||
|
setError(descErr);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSending(true);
|
||||||
|
try {
|
||||||
|
await submitSupportRequest({
|
||||||
|
email: email.trim(),
|
||||||
|
description: description.trim(),
|
||||||
|
});
|
||||||
|
setSuccess(true);
|
||||||
|
setDescription("");
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message || "Something went wrong.");
|
||||||
|
} finally {
|
||||||
|
setSending(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="support-page page">
|
||||||
|
<h1 className="support-page__title">Contact support</h1>
|
||||||
|
<p className="support-page__intro">
|
||||||
|
Describe your issue below. We will reply to the email address you
|
||||||
|
provide.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{error && <div className="alert alert--error support-page__alert">{error}</div>}
|
||||||
|
{success && (
|
||||||
|
<div className="alert alert--success support-page__alert">
|
||||||
|
Your message was sent. Thank you.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<form className="support-form" onSubmit={handleSubmit} noValidate>
|
||||||
|
<Input
|
||||||
|
label="Email"
|
||||||
|
type="email"
|
||||||
|
value={email}
|
||||||
|
onChange={setEmail}
|
||||||
|
autoComplete="email"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<label className="form-field support-form__description">
|
||||||
|
<div className="input__label">Description of Issue</div>
|
||||||
|
<textarea
|
||||||
|
className="form-control support-form__textarea"
|
||||||
|
value={description}
|
||||||
|
onChange={(e) => setDescription(e.target.value)}
|
||||||
|
rows={8}
|
||||||
|
maxLength={DESCRIPTION_MAX}
|
||||||
|
placeholder="Letters, numbers, and spaces only"
|
||||||
|
aria-required="true"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<Button type="submit" disabled={sending} className="support-form__submit">
|
||||||
|
{sending ? "Sending…" : "Send"}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
29
frontend/src/pages/terms/Terms.css
Normal file
29
frontend/src/pages/terms/Terms.css
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
.tos-container {
|
||||||
|
max-width: 800px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 2rem;
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tos-container h1 {
|
||||||
|
font-size: 2rem;
|
||||||
|
font-weight: 200;
|
||||||
|
margin-bottom: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tos-para {
|
||||||
|
margin-bottom: 1.25rem;
|
||||||
|
font-size: 1.35rem;
|
||||||
|
font-weight: 200;
|
||||||
|
line-height: 1.65;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tos-para:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tos-para strong {
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
141
frontend/src/pages/terms/Terms.jsx
Normal file
141
frontend/src/pages/terms/Terms.jsx
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
import "./Terms.css";
|
||||||
|
|
||||||
|
export default function Terms() {
|
||||||
|
return (
|
||||||
|
<div className="tos-container">
|
||||||
|
<h1>Terms of Service And Use</h1>
|
||||||
|
<p className="tos-para">
|
||||||
|
YOU SHOULD READ THESE TERMS OF SERVICE AND USE BEFORE USING THIS WEB
|
||||||
|
SITE.
|
||||||
|
</p>
|
||||||
|
<p className="tos-para">
|
||||||
|
By using the Website, you acknoweldge that you have read this, and
|
||||||
|
that you understand it. The Website which is located at the domain
|
||||||
|
Koin Ping.com and Koin Ping.ai ("Koin Ping") and/or any mobile
|
||||||
|
apps available for download (collectively, the "Web Site,"
|
||||||
|
"Websites" and "Apps") are governed by these Terms
|
||||||
|
of Service and Use (the "Agreement," "Terms of
|
||||||
|
Service", or "TOS"). Any reference herein to the
|
||||||
|
Website, Websites, and/or Apps is intended, and shall be construed, to
|
||||||
|
pertain to one, all, or any of them, indivuidually and collectively.
|
||||||
|
</p>
|
||||||
|
<p className="tos-para">
|
||||||
|
By using the Web Site and Apps, you ("you" or "User")
|
||||||
|
signify your acceptance of this Agreement and your acknowledgement that
|
||||||
|
all information that you provide, directly or indirectly, through the Web
|
||||||
|
Sites and App will be managed in accordance with the Privacy Notice. IF
|
||||||
|
YOU DO NOT ACCEPT THESE TERMS OF SERVICE AND USE, YOU ARE NOT AUTHORIZED
|
||||||
|
TO ACCESS OR USE THE WEB SITE OR APPS.
|
||||||
|
</p>
|
||||||
|
<p className="tos-para">
|
||||||
|
<strong>Communications.</strong> You agree that by providing your contact information,
|
||||||
|
you consent to receiving communication, in connection with your
|
||||||
|
Membership subscription. This may include communication about your
|
||||||
|
account, features, and services via e-email, push notification, phone,
|
||||||
|
or text message (including by an automatic telephone dialing system
|
||||||
|
and/or with an artificial or pre-recorded voice) at any of the phone
|
||||||
|
numbers provided by you or on your behalf. Standard text messaging
|
||||||
|
charges applied by your cell phone carrier may apply.
|
||||||
|
</p>
|
||||||
|
<p className="tos-para">
|
||||||
|
<strong>User Eligibility.</strong> The Website should only be accessed and used by
|
||||||
|
individuals who agree to be bound by these Terms of Use and who are at
|
||||||
|
least 18 years of age. The Websites may be accessible worldwide;
|
||||||
|
however, the Websites are intended for use only in the USA and Canada.
|
||||||
|
If you access/use the Websites from outside the USA or Canada, you do
|
||||||
|
so at your own risk and are responsible for complying with the laws
|
||||||
|
and regulations of the territory from which you access/use the
|
||||||
|
Websites.
|
||||||
|
</p>
|
||||||
|
<p className="tos-para">
|
||||||
|
<strong>Intellectual property rights.</strong> The Web Site and the information, computer
|
||||||
|
code, and related functionality appearing, featured or otherwise
|
||||||
|
displayed on the Websites are owned by Koin Ping, its affiliates, and
|
||||||
|
their respective licensors or other third parties and protected under
|
||||||
|
the copyright, trademark and other laws of the United States and other
|
||||||
|
countries sand international treaty provisions.
|
||||||
|
</p>
|
||||||
|
<p className="tos-para">
|
||||||
|
<strong>Limited license.</strong> Koin Ping grants to you a limited, non-exclusive,
|
||||||
|
non-transferable license to use the Web Site in strict accordance with
|
||||||
|
these Terms of Service and Use and the instructions provided by us on
|
||||||
|
the Web Site. The materials provided on the Web Site, including,
|
||||||
|
without limitation, the Information, computer code, and related
|
||||||
|
functionality, are for your personal use only. Except as may be
|
||||||
|
explicitly permitted through the Websites, you may not copy, modify,
|
||||||
|
upload, republish, distribute, display, post, license, create
|
||||||
|
derivative works from, or transmit anything you obtain from the Web
|
||||||
|
Sites, including anything you download from the Websites, unless you
|
||||||
|
first obtain our written consent.
|
||||||
|
</p>
|
||||||
|
<p className="tos-para">
|
||||||
|
Any rights not expressly granted herein are reserved to Koin Ping and
|
||||||
|
its affiliates. You may not remove, obscure, or otherwise deface
|
||||||
|
proprietary notices appearing on the Web Site, or any content or
|
||||||
|
Information. Any unauthorized use of the Websites or its contents may
|
||||||
|
violate copyright laws, trademark laws, the laws of privacy and
|
||||||
|
publicity and communications regulations and statutes.
|
||||||
|
</p>
|
||||||
|
<p className="tos-para">
|
||||||
|
<strong>Restrictions on Use.</strong> As a condition of your use of the Websites, you
|
||||||
|
warrant that you will not use the Websites for any purpose that is
|
||||||
|
unlawful or prohibited by these terms, conditions and notices. You may
|
||||||
|
not use the Websites in any way that could damage, disable, overburden
|
||||||
|
or impair the Websites or interfere with any other party's use and
|
||||||
|
enjoyment of the Websites. You may not obtain or attempt to obtain any
|
||||||
|
materials or information through any means not intentionally made
|
||||||
|
available or provided for through the Web Site.
|
||||||
|
</p>
|
||||||
|
<p className="tos-para">
|
||||||
|
<strong>Revocation of privileges.</strong> You agree that your use of the Websites may
|
||||||
|
be suspended or terminated immediately upon receipt of any notice which
|
||||||
|
alleges that you have used the Websites in violation of these Terms of
|
||||||
|
Use and/or for any purpose that violates any local, state, federal or
|
||||||
|
law of the USA or other jurisdictions, including, but not limited to,
|
||||||
|
the posting of information that may violate third party rights, may
|
||||||
|
defame a third party, may be obscene or pornographic, may harass or
|
||||||
|
assault others, or may violate any laws, rules or regulations,
|
||||||
|
including, hacking or other criminal regulations. You understand that
|
||||||
|
actions in violation of these Terms of Use may subject you to serious
|
||||||
|
civil and criminal legal penalties and Koin Ping reserves the right to
|
||||||
|
pursue penalties and other remedies to the fullest extent of the law to
|
||||||
|
protect our rights.
|
||||||
|
</p>
|
||||||
|
<p className="tos-para">
|
||||||
|
<strong>No Warranties.</strong> Koin Ping MAKES NO REPRESENTATIONS OR WARRANTIES ABOUT
|
||||||
|
THE WEB SITE, THE SUITABILITY OF THE INFORMATION CONTAINED ON OR
|
||||||
|
RECEIVED THROUGH THE WEB SITE, OR ANY SERVICES OR PRODUCTS RECEIVED
|
||||||
|
THROUGH THE WEB SITE. ALL INFORMATION AND USE OF THE WEB SITE ARE
|
||||||
|
PROVIDED "AS IS" WITHOUT WARRANTY OF ANY KIND. Koin Ping HEREBY
|
||||||
|
EXPRESSLY DISCLAIMS ALL WARRANTIES WITH REGARD TO THE Websites, THE
|
||||||
|
INFORMATION CONTAINED ON OR RECEIVED THROUGH USE OF THE Websites AND
|
||||||
|
ANY SERVICES OR PRODUCTS RECEIVED THROUGH THE Websites, INCLUDING ALL
|
||||||
|
EXPRESS, STATUTORY AND IMPLIED WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||||
|
FOR A PARTICULAR PURPOSE, TITLE AND NON-INFRINGEMENT. Koin Ping DOES
|
||||||
|
NOT WARRANT THAT THE CONTENTS OR ANY INFORMATION RECEIVED THROUGH THE
|
||||||
|
Websites ARE ACCURATE, RELIABLE OR CORRECT; THAT THE Websites WILL BE
|
||||||
|
AVAILABLE AT ANY PARTICULAR TIME OR LOCATION; THAT ANY DEFECTS OR
|
||||||
|
ERRORS WILL BE CORRECTED; OR THAT THE CONTENTS OR ANY INFORMATION
|
||||||
|
RECEIVED THROUGH THE Websites ARE FREE OF VIRUSES OR OTHER DESTRUCTIVE
|
||||||
|
OR HARMFUL COMPONENTS. YOUR USE OF THE Websites IS SOLELY AT YOUR OWN
|
||||||
|
RISK. USER EXPRESSLY AGREES THAT IT HAS RELIED ON NO WARRANTIES,
|
||||||
|
REPRESENTATIONS OR STATEMENTS OTHER THAN IN THIS AGREEMENT.
|
||||||
|
</p>
|
||||||
|
<p className="tos-para">
|
||||||
|
<strong>Limitation of Liability.</strong> UNDER NO CIRCUMSTANCES SHALL Koin Ping BE
|
||||||
|
LIABLE FOR ANY DAMAGES, INCLUDING, WITHOUT LIMITATION, DIRECT,
|
||||||
|
INDIRECT, PUNITIVE, INCIDENTAL, SPECIAL OR CONSEQUENTIAL DAMAGES OR LOST
|
||||||
|
PROFITS THAT RESULT FROM, OR ARISE OUT OF OR IN CONNECTION WITH THE USE
|
||||||
|
OF, OR INABILITY TO USE THE Websites, THE INFORMATION CONTAINED ON OR
|
||||||
|
RECEIVED THROUGH USE OF THE Websites, OR ANY SERVICES OR PRODUCTS
|
||||||
|
RECEIVED THROUGH THE Websites. THIS LIMITATION APPLIES WHETHER THE
|
||||||
|
ALLEGED LIABILITY IS BASED ON CONTRACT, TORT, NEGLIGENCE, STRICT
|
||||||
|
LIABILITY OR ANY OTHER BASIS, EVEN IF Koin Ping HAS BEEN ADVISED OF THE
|
||||||
|
POSSIBILITY OF SUCH DAMAGES. BECAUSE SOME JURISDICTIONS DO NOT ALLOW THE
|
||||||
|
EXCLUSION OR LIMITATION OF INCIDENTAL OR CONSEQUENTIAL DAMAGES, Koin
|
||||||
|
Ping's LIABILITY IN SUCH JURISDICTIONS SHALL BE LIMITED TO THE
|
||||||
|
MAXIMUM EXTENT PERMITTED BY THE LAW OF YOUR JURISDICTION.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,10 +1,18 @@
|
|||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
import { updatePassword } from "firebase/auth";
|
import { updatePassword } from "firebase/auth";
|
||||||
import { auth } from "../../firebase/config";
|
import { auth } from "../../firebase/config";
|
||||||
import { getAccount, createPortalSession } from "../../api/account";
|
import { getAccount, createPortalSession } from "../../api/account";
|
||||||
import "./Account.css";
|
import "./Account.css";
|
||||||
|
|
||||||
|
const TIER_LABELS = {
|
||||||
|
free: "Free Trial",
|
||||||
|
premium: "Premium",
|
||||||
|
pro: "Pro",
|
||||||
|
};
|
||||||
|
|
||||||
export default function Account() {
|
export default function Account() {
|
||||||
|
const navigate = useNavigate();
|
||||||
const [account, setAccount] = useState(null);
|
const [account, setAccount] = useState(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState(null);
|
const [error, setError] = useState(null);
|
||||||
@@ -78,6 +86,7 @@ export default function Account() {
|
|||||||
return <div className="page text-error">Error: {error}</div>;
|
return <div className="page text-error">Error: {error}</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const tier = account.subscription_tier || "free";
|
||||||
const isCanceling = account.cancel_at_period_end;
|
const isCanceling = account.cancel_at_period_end;
|
||||||
const statusLabel = isCanceling
|
const statusLabel = isCanceling
|
||||||
? "Canceling"
|
? "Canceling"
|
||||||
@@ -86,6 +95,9 @@ export default function Account() {
|
|||||||
: account.subscription_status.charAt(0).toUpperCase() +
|
: account.subscription_status.charAt(0).toUpperCase() +
|
||||||
account.subscription_status.slice(1);
|
account.subscription_status.slice(1);
|
||||||
|
|
||||||
|
const canUpgrade = tier === "free" || tier === "premium";
|
||||||
|
const hasPaidSub = tier !== "free";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="page account-page">
|
<div className="page account-page">
|
||||||
<h1 className="mb-lg">Account</h1>
|
<h1 className="mb-lg">Account</h1>
|
||||||
@@ -114,7 +126,7 @@ export default function Account() {
|
|||||||
<h2 className="account__section-title">Subscription</h2>
|
<h2 className="account__section-title">Subscription</h2>
|
||||||
<div className="account__row">
|
<div className="account__row">
|
||||||
<span className="account__label">Plan</span>
|
<span className="account__label">Plan</span>
|
||||||
<span className="account__value">{account.subscription_plan}</span>
|
<span className="account__value">{TIER_LABELS[tier] || account.subscription_plan}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="account__row">
|
<div className="account__row">
|
||||||
<span className="account__label">Status</span>
|
<span className="account__label">Status</span>
|
||||||
@@ -149,15 +161,27 @@ export default function Account() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="account__portal-section">
|
<div className="account__portal-section">
|
||||||
<button
|
{canUpgrade && (
|
||||||
onClick={handleManageSubscription}
|
<button
|
||||||
disabled={portalLoading}
|
onClick={() => navigate("/subscribe")}
|
||||||
className="btn btn--ghost"
|
className="btn btn--primary"
|
||||||
>
|
>
|
||||||
{portalLoading ? "Redirecting..." : "Manage Subscription"}
|
Upgrade Plan
|
||||||
</button>
|
</button>
|
||||||
|
)}
|
||||||
|
{hasPaidSub && (
|
||||||
|
<button
|
||||||
|
onClick={handleManageSubscription}
|
||||||
|
disabled={portalLoading}
|
||||||
|
className="btn btn--ghost"
|
||||||
|
>
|
||||||
|
{portalLoading ? "Redirecting..." : "Manage Subscription"}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
<p className="text-dimmed text-sm account__portal-hint">
|
<p className="text-dimmed text-sm account__portal-hint">
|
||||||
Cancel subscription, update payment method, or view invoices via Stripe.
|
{hasPaidSub
|
||||||
|
? "Cancel subscription, update payment method, or view invoices via Stripe."
|
||||||
|
: "Upgrade to unlock more addresses, alert types, and notification channels."}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
BIN
memberships.pdf
Normal file
BIN
memberships.pdf
Normal file
Binary file not shown.
Reference in New Issue
Block a user