Compare commits
133 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d2babbe3b0 | ||
|
|
684d81db2a | ||
| 59ffa65562 | |||
| 0c0dd852ac | |||
|
|
cde766872e | ||
| 604b543c12 | |||
| fd67fe2941 | |||
|
|
582035b60e | ||
| 44e7670a89 | |||
| 2abfb3ed6e | |||
|
|
219de4a25c | ||
| 1540d5051f | |||
| 9c978c26fa | |||
|
|
adb109d8e9 | ||
| c668c8785f | |||
|
|
695bbb61b9 | ||
| 877c971833 | |||
| ed3af07aab | |||
|
|
dd4b34edfa | ||
| 91fa2f0516 | |||
|
|
aefd57e57b | ||
| 2ca4eb47ac | |||
| a4fe30da22 | |||
|
|
abab7fd25e | ||
| 53dd26d2d9 | |||
| ab3da0336c | |||
|
|
ed6d6349a2 | ||
| d4db2a709a | |||
| 508583809b | |||
|
|
6b1f7e7590 | ||
| 07bb31f4fb | |||
| a42fb76da8 | |||
|
|
08c320423c | ||
| d2498065ed | |||
| 56dc96f418 | |||
|
|
4e9aa0efc3 | ||
| e5e4b1316c | |||
| e8d511b4de | |||
|
|
c4bbf5c251 | ||
| 32a9e6732b | |||
| e7c076e2ed | |||
|
|
dbe8e72efe | ||
| 38bd193042 | |||
|
|
57215e2778 | ||
| 2c1de24e9a | |||
| c8baff7aac | |||
| de3f21a7ec | |||
|
|
c6adbf79e7 | ||
| 7399a27600 | |||
|
|
68aadcaa4e | ||
| 971d2c3fa7 | |||
|
|
daaacfde5e | ||
| 7ac8fe1d29 | |||
| a2462dfb6b | |||
|
|
a911224fb4 | ||
|
|
bf4bcef890 | ||
| ac6cd2e0a1 | |||
| eea03880c1 | |||
|
|
7fc263691f | ||
| c0912d36d5 | |||
| 612c2b5943 | |||
|
|
8e787ddcf0 | ||
| 11c52d284c | |||
|
|
b528bd3651 | ||
| 4c5ceb1bd6 | |||
| bcc4ad64dc | |||
|
|
d520980322 | ||
| d79955aaa0 | |||
| e66027dc8e | |||
|
|
027df989a4 | ||
| d4d69caaf7 | |||
| 03b5af39e1 | |||
|
|
8a86333f86 | ||
| f173f805ea | |||
| d3b0996ad5 | |||
|
|
b939262f0c | ||
| 9437f3d6c6 | |||
| f1e028d498 | |||
|
|
5274650aea | ||
| de5a9a565b | |||
| 10a379c5e3 | |||
| a6a484d432 | |||
|
|
4b0a172c35 | ||
| e8c894d5cf | |||
| 6c8fd4b126 | |||
|
|
a1f52544d0 | ||
| 2334359756 | |||
| 406954ca06 | |||
|
|
95d441be98 | ||
| 186ed484b7 | |||
|
|
3669958e9d | ||
| 5f3daf0539 | |||
| ae7afaaf97 | |||
|
|
3ae7b9e0d4 | ||
| 921c48fc57 | |||
|
|
2571864b91 | ||
| 065d0c746a | |||
| 395f6c21a2 | |||
|
|
aec56dfc23 | ||
| a12a0e5207 | |||
| e337bd67b1 | |||
|
|
a8f5b4e51a | ||
| d0ce8021d6 | |||
| efbb162880 | |||
|
|
e353ce8a81 | ||
| b5cbf271b8 | |||
|
|
2041b4ac3c | ||
| e547363a65 | |||
| bddaf765fc | |||
|
|
3c0bebb65c | ||
| 265cc3ffd4 | |||
| 3d5767b60b | |||
|
|
e9cb45efe0 | ||
| 99a57f3a30 | |||
| e46f5eb7f6 | |||
|
|
034887069c | ||
| 84b5e0e15e | |||
| dc0f774699 | |||
|
|
1195b7e87f | ||
| e9889f1f1e | |||
| 3c7f6429aa | |||
|
|
0db90dfaa6 | ||
| b7a1294ae6 | |||
|
|
be652f9790 | ||
| 1a3e6a9ab5 | |||
|
|
262396ddd0 | ||
| c542796048 | |||
| 5b8f309ad8 | |||
|
|
6a73659f85 | ||
| 22513a967b | |||
| a10f84aa48 | |||
|
|
621d30b84f | ||
| ed857f588a |
18
.devcontainer/devcontainer.json
Normal file
18
.devcontainer/devcontainer.json
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"name": "Flyer Crawler Dev (Ubuntu 22.04)",
|
||||||
|
"dockerComposeFile": ["../compose.dev.yml"],
|
||||||
|
"service": "app",
|
||||||
|
"workspaceFolder": "/app",
|
||||||
|
"customizations": {
|
||||||
|
"vscode": {
|
||||||
|
"extensions": ["dbaeumer.vscode-eslint", "esbenp.prettier-vscode"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"remoteUser": "root",
|
||||||
|
// Automatically install dependencies when the container is created.
|
||||||
|
// This runs inside the container, populating the isolated node_modules volume.
|
||||||
|
"postCreateCommand": "npm install",
|
||||||
|
"postAttachCommand": "npm run dev:container",
|
||||||
|
// Try to start podman machine, but exit with success (0) even if it's already running
|
||||||
|
"initializeCommand": "powershell -Command \"podman machine start; exit 0\""
|
||||||
|
}
|
||||||
@@ -47,6 +47,19 @@ jobs:
|
|||||||
- name: Install Dependencies
|
- name: Install Dependencies
|
||||||
run: npm ci
|
run: npm ci
|
||||||
|
|
||||||
|
- name: Bump Minor Version and Push
|
||||||
|
run: |
|
||||||
|
# Configure git for the commit.
|
||||||
|
git config --global user.name 'Gitea Actions'
|
||||||
|
git config --global user.email 'actions@gitea.projectium.com'
|
||||||
|
|
||||||
|
# Bump the minor version number. This creates a new commit and a new tag.
|
||||||
|
# The commit message includes [skip ci] to prevent this push from triggering another workflow run.
|
||||||
|
npm version minor -m "ci: Bump version to %s for production release [skip ci]"
|
||||||
|
|
||||||
|
# Push the new commit and the new tag back to the main branch.
|
||||||
|
git push --follow-tags
|
||||||
|
|
||||||
- name: Check for Production Database Schema Changes
|
- name: Check for Production Database Schema Changes
|
||||||
env:
|
env:
|
||||||
DB_HOST: ${{ secrets.DB_HOST }}
|
DB_HOST: ${{ secrets.DB_HOST }}
|
||||||
@@ -61,9 +74,10 @@ jobs:
|
|||||||
echo "--- Checking for production schema changes ---"
|
echo "--- Checking for production schema changes ---"
|
||||||
CURRENT_HASH=$(cat sql/master_schema_rollup.sql | dos2unix | sha256sum | awk '{ print $1 }')
|
CURRENT_HASH=$(cat sql/master_schema_rollup.sql | dos2unix | sha256sum | awk '{ print $1 }')
|
||||||
echo "Current Git Schema Hash: $CURRENT_HASH"
|
echo "Current Git Schema Hash: $CURRENT_HASH"
|
||||||
DEPLOYED_HASH=$(PGPASSWORD="$DB_PASSWORD" psql -v ON_ERROR_STOP=1 -h "$DB_HOST" -p 5432 -U "$DB_USER" -d "$DB_NAME" -c "SELECT schema_hash FROM public.schema_info WHERE environment = 'production';" -t -A || echo "none")
|
# The psql command will now fail the step if the query errors (e.g., column missing), preventing deployment on a bad schema.
|
||||||
|
DEPLOYED_HASH=$(PGPASSWORD="$DB_PASSWORD" psql -v ON_ERROR_STOP=1 -h "$DB_HOST" -p 5432 -U "$DB_USER" -d "$DB_NAME" -c "SELECT schema_hash FROM public.schema_info WHERE environment = 'production';" -t -A)
|
||||||
echo "Deployed DB Schema Hash: $DEPLOYED_HASH"
|
echo "Deployed DB Schema Hash: $DEPLOYED_HASH"
|
||||||
if [ "$DEPLOYED_HASH" = "none" ] || [ -z "$DEPLOYED_HASH" ]; then
|
if [ -z "$DEPLOYED_HASH" ]; then
|
||||||
echo "WARNING: No schema hash found in the production database. This is expected for a first-time deployment."
|
echo "WARNING: No schema hash found in the production database. This is expected for a first-time deployment."
|
||||||
elif [ "$CURRENT_HASH" != "$DEPLOYED_HASH" ]; then
|
elif [ "$CURRENT_HASH" != "$DEPLOYED_HASH" ]; then
|
||||||
echo "ERROR: Database schema mismatch detected! A manual database migration is required."
|
echo "ERROR: Database schema mismatch detected! A manual database migration is required."
|
||||||
@@ -79,8 +93,9 @@ jobs:
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
GITEA_SERVER_URL="https://gitea.projectium.com"
|
GITEA_SERVER_URL="https://gitea.projectium.com"
|
||||||
COMMIT_MESSAGE=$(git log -1 --pretty=%s)
|
COMMIT_MESSAGE=$(git log -1 --grep="\[skip ci\]" --invert-grep --pretty=%s)
|
||||||
VITE_APP_VERSION="$(date +'%Y%m%d-%H%M'):$(git rev-parse --short HEAD)" \
|
PACKAGE_VERSION=$(node -p "require('./package.json').version")
|
||||||
|
VITE_APP_VERSION="$(date +'%Y%m%d-%H%M'):$(git rev-parse --short HEAD):$PACKAGE_VERSION" \
|
||||||
VITE_APP_COMMIT_URL="$GITEA_SERVER_URL/${{ gitea.repository }}/commit/${{ gitea.sha }}" \
|
VITE_APP_COMMIT_URL="$GITEA_SERVER_URL/${{ gitea.repository }}/commit/${{ gitea.sha }}" \
|
||||||
VITE_APP_COMMIT_MESSAGE="$COMMIT_MESSAGE" \
|
VITE_APP_COMMIT_MESSAGE="$COMMIT_MESSAGE" \
|
||||||
VITE_API_BASE_URL=/api VITE_API_KEY=${{ secrets.VITE_GOOGLE_GENAI_API_KEY }} npm run build
|
VITE_API_BASE_URL=/api VITE_API_KEY=${{ secrets.VITE_GOOGLE_GENAI_API_KEY }} npm run build
|
||||||
@@ -148,7 +163,12 @@ jobs:
|
|||||||
echo "Updating schema hash in production database..."
|
echo "Updating schema hash in production database..."
|
||||||
CURRENT_HASH=$(cat sql/master_schema_rollup.sql | dos2unix | sha256sum | awk '{ print $1 }')
|
CURRENT_HASH=$(cat sql/master_schema_rollup.sql | dos2unix | sha256sum | awk '{ print $1 }')
|
||||||
PGPASSWORD="$DB_PASSWORD" psql -v ON_ERROR_STOP=1 -h "$DB_HOST" -p 5432 -U "$DB_USER" -d "$DB_NAME" -c \
|
PGPASSWORD="$DB_PASSWORD" psql -v ON_ERROR_STOP=1 -h "$DB_HOST" -p 5432 -U "$DB_USER" -d "$DB_NAME" -c \
|
||||||
"INSERT INTO public.schema_info (environment, schema_hash, deployed_at) VALUES ('production', '$CURRENT_HASH', NOW())
|
"CREATE TABLE IF NOT EXISTS public.schema_info (
|
||||||
|
environment VARCHAR(50) PRIMARY KEY,
|
||||||
|
schema_hash VARCHAR(64) NOT NULL,
|
||||||
|
deployed_at TIMESTAMP DEFAULT NOW()
|
||||||
|
);
|
||||||
|
INSERT INTO public.schema_info (environment, schema_hash, deployed_at) VALUES ('production', '$CURRENT_HASH', NOW())
|
||||||
ON CONFLICT (environment) DO UPDATE SET schema_hash = EXCLUDED.schema_hash, deployed_at = NOW();"
|
ON CONFLICT (environment) DO UPDATE SET schema_hash = EXCLUDED.schema_hash, deployed_at = NOW();"
|
||||||
|
|
||||||
UPDATED_HASH=$(PGPASSWORD="$DB_PASSWORD" psql -v ON_ERROR_STOP=1 -h "$DB_HOST" -p 5432 -U "$DB_USER" -d "$DB_NAME" -c "SELECT schema_hash FROM public.schema_info WHERE environment = 'production';" -t -A)
|
UPDATED_HASH=$(PGPASSWORD="$DB_PASSWORD" psql -v ON_ERROR_STOP=1 -h "$DB_HOST" -p 5432 -U "$DB_USER" -d "$DB_NAME" -c "SELECT schema_hash FROM public.schema_info WHERE environment = 'production';" -t -A)
|
||||||
|
|||||||
@@ -51,7 +51,14 @@ jobs:
|
|||||||
|
|
||||||
# Bump the patch version number. This creates a new commit and a new tag.
|
# Bump the patch version number. This creates a new commit and a new tag.
|
||||||
# The commit message includes [skip ci] to prevent this push from triggering another workflow run.
|
# The commit message includes [skip ci] to prevent this push from triggering another workflow run.
|
||||||
npm version patch -m "ci: Bump version to %s [skip ci]"
|
# If the tag already exists (e.g. re-running a failed job), we skip the conflicting version.
|
||||||
|
if ! npm version patch -m "ci: Bump version to %s [skip ci]"; then
|
||||||
|
echo "⚠️ Version bump failed (likely tag exists). Attempting to skip to next version..."
|
||||||
|
# Bump package.json to the conflicting version without git tagging
|
||||||
|
npm version patch --no-git-tag-version > /dev/null
|
||||||
|
# Bump again to the next version, forcing it because the directory is now dirty
|
||||||
|
npm version patch -m "ci: Bump version to %s [skip ci]" --force
|
||||||
|
fi
|
||||||
|
|
||||||
# Push the new commit and the new tag back to the main branch.
|
# Push the new commit and the new tag back to the main branch.
|
||||||
git push --follow-tags
|
git push --follow-tags
|
||||||
@@ -112,6 +119,11 @@ jobs:
|
|||||||
# --- JWT Secret for Passport authentication in tests ---
|
# --- JWT Secret for Passport authentication in tests ---
|
||||||
JWT_SECRET: ${{ secrets.JWT_SECRET }}
|
JWT_SECRET: ${{ secrets.JWT_SECRET }}
|
||||||
|
|
||||||
|
# --- V8 Coverage for Server Process ---
|
||||||
|
# This variable tells the Node.js process (our server, started by globalSetup)
|
||||||
|
# where to output its raw V8 coverage data.
|
||||||
|
NODE_V8_COVERAGE: '.coverage/tmp/integration-server'
|
||||||
|
|
||||||
# --- Increase Node.js memory limit to prevent heap out of memory errors ---
|
# --- Increase Node.js memory limit to prevent heap out of memory errors ---
|
||||||
# This is crucial for memory-intensive tasks like running tests and coverage.
|
# This is crucial for memory-intensive tasks like running tests and coverage.
|
||||||
NODE_OPTIONS: '--max-old-space-size=8192'
|
NODE_OPTIONS: '--max-old-space-size=8192'
|
||||||
@@ -129,10 +141,40 @@ jobs:
|
|||||||
# Run unit and integration tests as separate steps.
|
# Run unit and integration tests as separate steps.
|
||||||
# The `|| true` ensures the workflow continues even if tests fail, allowing coverage to run.
|
# The `|| true` ensures the workflow continues even if tests fail, allowing coverage to run.
|
||||||
echo "--- Running Unit Tests ---"
|
echo "--- Running Unit Tests ---"
|
||||||
npm run test:unit -- --coverage --reporter=verbose --includeTaskLocation --testTimeout=10000 --silent=passed-only || true
|
# npm run test:unit -- --coverage --reporter=verbose --includeTaskLocation --testTimeout=10000 --silent=passed-only || true
|
||||||
|
npm run test:unit -- --coverage \
|
||||||
|
--coverage.exclude='**/*.test.ts' \
|
||||||
|
--coverage.exclude='**/tests/**' \
|
||||||
|
--coverage.exclude='**/mocks/**' \
|
||||||
|
--coverage.exclude='src/components/icons/**' \
|
||||||
|
--coverage.exclude='src/db/**' \
|
||||||
|
--coverage.exclude='src/lib/**' \
|
||||||
|
--coverage.exclude='src/types/**' \
|
||||||
|
--reporter=verbose --includeTaskLocation --testTimeout=10000 --silent=passed-only --no-file-parallelism || true
|
||||||
|
|
||||||
echo "--- Running Integration Tests ---"
|
echo "--- Running Integration Tests ---"
|
||||||
npm run test:integration -- --coverage --reporter=verbose --includeTaskLocation --testTimeout=10000 --silent=passed-only || true
|
npm run test:integration -- --coverage \
|
||||||
|
--coverage.exclude='**/*.test.ts' \
|
||||||
|
--coverage.exclude='**/tests/**' \
|
||||||
|
--coverage.exclude='**/mocks/**' \
|
||||||
|
--coverage.exclude='src/components/icons/**' \
|
||||||
|
--coverage.exclude='src/db/**' \
|
||||||
|
--coverage.exclude='src/lib/**' \
|
||||||
|
--coverage.exclude='src/types/**' \
|
||||||
|
--reporter=verbose --includeTaskLocation --testTimeout=10000 --silent=passed-only || true
|
||||||
|
|
||||||
|
echo "--- Running E2E Tests ---"
|
||||||
|
# Run E2E tests using the dedicated E2E config which inherits from integration config.
|
||||||
|
# We still pass --coverage to enable it, but directory and timeout are now in the config.
|
||||||
|
npx vitest run --config vitest.config.e2e.ts --coverage \
|
||||||
|
--coverage.exclude='**/*.test.ts' \
|
||||||
|
--coverage.exclude='**/tests/**' \
|
||||||
|
--coverage.exclude='**/mocks/**' \
|
||||||
|
--coverage.exclude='src/components/icons/**' \
|
||||||
|
--coverage.exclude='src/db/**' \
|
||||||
|
--coverage.exclude='src/lib/**' \
|
||||||
|
--coverage.exclude='src/types/**' \
|
||||||
|
--reporter=verbose --no-file-parallelism || true
|
||||||
|
|
||||||
# Re-enable secret masking for subsequent steps.
|
# Re-enable secret masking for subsequent steps.
|
||||||
echo "::secret-masking::"
|
echo "::secret-masking::"
|
||||||
@@ -148,6 +190,7 @@ jobs:
|
|||||||
echo "Checking for source coverage files..."
|
echo "Checking for source coverage files..."
|
||||||
ls -l .coverage/unit/coverage-final.json
|
ls -l .coverage/unit/coverage-final.json
|
||||||
ls -l .coverage/integration/coverage-final.json
|
ls -l .coverage/integration/coverage-final.json
|
||||||
|
ls -l .coverage/e2e/coverage-final.json || echo "E2E coverage file not found"
|
||||||
|
|
||||||
# --- V8 Coverage Processing for Backend Server ---
|
# --- V8 Coverage Processing for Backend Server ---
|
||||||
# The integration tests start the server, which generates raw V8 coverage data.
|
# The integration tests start the server, which generates raw V8 coverage data.
|
||||||
@@ -160,7 +203,7 @@ jobs:
|
|||||||
# Run c8: read raw files from the temp dir, and output an Istanbul JSON report.
|
# Run c8: read raw files from the temp dir, and output an Istanbul JSON report.
|
||||||
# We only generate the 'json' report here because it's all nyc needs for merging.
|
# We only generate the 'json' report here because it's all nyc needs for merging.
|
||||||
echo "Server coverage report about to be generated..."
|
echo "Server coverage report about to be generated..."
|
||||||
npx c8 report --reporter=json --temp-directory .coverage/tmp/integration-server --reports-dir .coverage/integration-server
|
npx c8 report --exclude='**/*.test.ts' --exclude='**/tests/**' --exclude='**/mocks/**' --reporter=json --temp-directory .coverage/tmp/integration-server --reports-dir .coverage/integration-server
|
||||||
echo "Server coverage report generated. Verifying existence:"
|
echo "Server coverage report generated. Verifying existence:"
|
||||||
ls -l .coverage/integration-server/coverage-final.json
|
ls -l .coverage/integration-server/coverage-final.json
|
||||||
|
|
||||||
@@ -179,6 +222,7 @@ jobs:
|
|||||||
# We give them unique names to be safe, though it's not strictly necessary.
|
# We give them unique names to be safe, though it's not strictly necessary.
|
||||||
cp .coverage/unit/coverage-final.json "$NYC_SOURCE_DIR/unit-coverage.json"
|
cp .coverage/unit/coverage-final.json "$NYC_SOURCE_DIR/unit-coverage.json"
|
||||||
cp .coverage/integration/coverage-final.json "$NYC_SOURCE_DIR/integration-coverage.json"
|
cp .coverage/integration/coverage-final.json "$NYC_SOURCE_DIR/integration-coverage.json"
|
||||||
|
cp .coverage/e2e/coverage-final.json "$NYC_SOURCE_DIR/e2e-coverage.json" || echo "E2E coverage file not found, skipping."
|
||||||
# This file might not exist if integration tests fail early, so we add `|| true`
|
# This file might not exist if integration tests fail early, so we add `|| true`
|
||||||
cp .coverage/integration-server/coverage-final.json "$NYC_SOURCE_DIR/integration-server-coverage.json" || echo "Server coverage file not found, skipping."
|
cp .coverage/integration-server/coverage-final.json "$NYC_SOURCE_DIR/integration-server-coverage.json" || echo "Server coverage file not found, skipping."
|
||||||
echo "Copied coverage files to source directory. Contents:"
|
echo "Copied coverage files to source directory. Contents:"
|
||||||
@@ -198,7 +242,10 @@ jobs:
|
|||||||
--reporter=text \
|
--reporter=text \
|
||||||
--reporter=html \
|
--reporter=html \
|
||||||
--report-dir .coverage/ \
|
--report-dir .coverage/ \
|
||||||
--temp-dir "$NYC_SOURCE_DIR"
|
--temp-dir "$NYC_SOURCE_DIR" \
|
||||||
|
--exclude "**/*.test.ts" \
|
||||||
|
--exclude "**/tests/**" \
|
||||||
|
--exclude "**/mocks/**"
|
||||||
|
|
||||||
# Re-enable secret masking for subsequent steps.
|
# Re-enable secret masking for subsequent steps.
|
||||||
echo "::secret-masking::"
|
echo "::secret-masking::"
|
||||||
@@ -249,18 +296,19 @@ jobs:
|
|||||||
# We normalize line endings to ensure the hash is consistent across different OS environments.
|
# We normalize line endings to ensure the hash is consistent across different OS environments.
|
||||||
CURRENT_HASH=$(cat sql/master_schema_rollup.sql | dos2unix | sha256sum | awk '{ print $1 }')
|
CURRENT_HASH=$(cat sql/master_schema_rollup.sql | dos2unix | sha256sum | awk '{ print $1 }')
|
||||||
echo "Current Git Schema Hash: $CURRENT_HASH"
|
echo "Current Git Schema Hash: $CURRENT_HASH"
|
||||||
|
|
||||||
# Query the production database to get the hash of the deployed schema.
|
# Query the production database to get the hash of the deployed schema.
|
||||||
# The `psql` command requires PGPASSWORD to be set.
|
# The `psql` command requires PGPASSWORD to be set.
|
||||||
# `\t` sets tuples-only mode and `\A` unaligns output to get just the raw value.
|
# `\t` sets tuples-only mode and `\A` unaligns output to get just the raw value.
|
||||||
# The `|| echo "none"` ensures the command doesn't fail if the table or row doesn't exist yet.
|
# The psql command will now fail the step if the query errors (e.g., column missing), preventing deployment on a bad schema.
|
||||||
DEPLOYED_HASH=$(PGPASSWORD="$DB_PASSWORD" psql -v ON_ERROR_STOP=1 -h "$DB_HOST" -p 5432 -U "$DB_USER" -d "$DB_NAME" -c "SELECT schema_hash FROM public.schema_info WHERE environment = 'test';" -t -A || echo "none")
|
DEPLOYED_HASH=$(PGPASSWORD="$DB_PASSWORD" psql -v ON_ERROR_STOP=1 -h "$DB_HOST" -p 5432 -U "$DB_USER" -d "$DB_NAME" -c "SELECT schema_hash FROM public.schema_info WHERE environment = 'test';" -t -A)
|
||||||
echo "Deployed DB Schema Hash: $DEPLOYED_HASH"
|
echo "Deployed DB Schema Hash: $DEPLOYED_HASH"
|
||||||
|
|
||||||
# Check if the hash is "none" (command failed) OR if it's an empty string (table exists but is empty).
|
# Check if the hash is "none" (command failed) OR if it's an empty string (table exists but is empty).
|
||||||
if [ "$DEPLOYED_HASH" = "none" ] || [ -z "$DEPLOYED_HASH" ]; then
|
if [ -z "$DEPLOYED_HASH" ]; then
|
||||||
echo "WARNING: No schema hash found in the test database."
|
echo "WARNING: No schema hash found in the test database."
|
||||||
echo "This is expected for a first-time deployment. The hash will be set after a successful deployment."
|
echo "This is expected for a first-time deployment. The hash will be set after a successful deployment."
|
||||||
|
echo "--- Debug: Dumping schema_info table ---"
|
||||||
|
PGPASSWORD="$DB_PASSWORD" psql -v ON_ERROR_STOP=0 -h "$DB_HOST" -p 5432 -U "$DB_USER" -d "$DB_NAME" -P pager=off -c "SELECT * FROM public.schema_info;" || true
|
||||||
|
echo "----------------------------------------"
|
||||||
# We allow the deployment to continue, but a manual schema update is required.
|
# We allow the deployment to continue, but a manual schema update is required.
|
||||||
# You could choose to fail here by adding `exit 1`.
|
# You could choose to fail here by adding `exit 1`.
|
||||||
elif [ "$CURRENT_HASH" != "$DEPLOYED_HASH" ]; then
|
elif [ "$CURRENT_HASH" != "$DEPLOYED_HASH" ]; then
|
||||||
@@ -284,8 +332,9 @@ jobs:
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
GITEA_SERVER_URL="https://gitea.projectium.com" # Your Gitea instance URL
|
GITEA_SERVER_URL="https://gitea.projectium.com" # Your Gitea instance URL
|
||||||
COMMIT_MESSAGE=$(git log -1 --pretty=%s)
|
COMMIT_MESSAGE=$(git log -1 --grep="\[skip ci\]" --invert-grep --pretty=%s)
|
||||||
VITE_APP_VERSION="$(date +'%Y%m%d-%H%M'):$(git rev-parse --short HEAD)" \
|
PACKAGE_VERSION=$(node -p "require('./package.json').version")
|
||||||
|
VITE_APP_VERSION="$(date +'%Y%m%d-%H%M'):$(git rev-parse --short HEAD):$PACKAGE_VERSION" \
|
||||||
VITE_APP_COMMIT_URL="$GITEA_SERVER_URL/${{ gitea.repository }}/commit/${{ gitea.sha }}" \
|
VITE_APP_COMMIT_URL="$GITEA_SERVER_URL/${{ gitea.repository }}/commit/${{ gitea.sha }}" \
|
||||||
VITE_APP_COMMIT_MESSAGE="$COMMIT_MESSAGE" \
|
VITE_APP_COMMIT_MESSAGE="$COMMIT_MESSAGE" \
|
||||||
VITE_API_BASE_URL="https://flyer-crawler-test.projectium.com/api" VITE_API_KEY=${{ secrets.VITE_GOOGLE_GENAI_API_KEY_TEST }} npm run build
|
VITE_API_BASE_URL="https://flyer-crawler-test.projectium.com/api" VITE_API_KEY=${{ secrets.VITE_GOOGLE_GENAI_API_KEY_TEST }} npm run build
|
||||||
@@ -347,7 +396,7 @@ jobs:
|
|||||||
|
|
||||||
echo "Installing production dependencies and restarting test server..."
|
echo "Installing production dependencies and restarting test server..."
|
||||||
cd /var/www/flyer-crawler-test.projectium.com
|
cd /var/www/flyer-crawler-test.projectium.com
|
||||||
npm install --omit=dev # Install only production dependencies
|
npm install --omit=dev
|
||||||
# Use `startOrReload` with the ecosystem file. This is the standard, idempotent way to deploy.
|
# Use `startOrReload` with the ecosystem file. This is the standard, idempotent way to deploy.
|
||||||
# It will START the process if it's not running, or RELOAD it if it is.
|
# It will START the process if it's not running, or RELOAD it if it is.
|
||||||
# We also add `&& pm2 save` to persist the process list across server reboots.
|
# We also add `&& pm2 save` to persist the process list across server reboots.
|
||||||
@@ -359,7 +408,12 @@ jobs:
|
|||||||
echo "Updating schema hash in test database..."
|
echo "Updating schema hash in test database..."
|
||||||
CURRENT_HASH=$(cat sql/master_schema_rollup.sql | dos2unix | sha256sum | awk '{ print $1 }')
|
CURRENT_HASH=$(cat sql/master_schema_rollup.sql | dos2unix | sha256sum | awk '{ print $1 }')
|
||||||
PGPASSWORD="$DB_PASSWORD" psql -v ON_ERROR_STOP=1 -h "$DB_HOST" -p 5432 -U "$DB_USER" -d "$DB_NAME" -c \
|
PGPASSWORD="$DB_PASSWORD" psql -v ON_ERROR_STOP=1 -h "$DB_HOST" -p 5432 -U "$DB_USER" -d "$DB_NAME" -c \
|
||||||
"INSERT INTO public.schema_info (environment, schema_hash, deployed_at) VALUES ('test', '$CURRENT_HASH', NOW())
|
"CREATE TABLE IF NOT EXISTS public.schema_info (
|
||||||
|
environment VARCHAR(50) PRIMARY KEY,
|
||||||
|
schema_hash VARCHAR(64) NOT NULL,
|
||||||
|
deployed_at TIMESTAMP DEFAULT NOW()
|
||||||
|
);
|
||||||
|
INSERT INTO public.schema_info (environment, schema_hash, deployed_at) VALUES ('test', '$CURRENT_HASH', NOW())
|
||||||
ON CONFLICT (environment) DO UPDATE SET schema_hash = EXCLUDED.schema_hash, deployed_at = NOW();"
|
ON CONFLICT (environment) DO UPDATE SET schema_hash = EXCLUDED.schema_hash, deployed_at = NOW();"
|
||||||
|
|
||||||
# Verify the hash was updated
|
# Verify the hash was updated
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ jobs:
|
|||||||
DB_USER: ${{ secrets.DB_USER }}
|
DB_USER: ${{ secrets.DB_USER }}
|
||||||
DB_PASSWORD: ${{ secrets.DB_PASSWORD }}
|
DB_PASSWORD: ${{ secrets.DB_PASSWORD }}
|
||||||
DB_NAME: ${{ secrets.DB_DATABASE_PROD }}
|
DB_NAME: ${{ secrets.DB_DATABASE_PROD }}
|
||||||
BACKUP_DIR: "/var/www/backups" # Define a dedicated directory for backups
|
BACKUP_DIR: '/var/www/backups' # Define a dedicated directory for backups
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Validate Secrets and Inputs
|
- name: Validate Secrets and Inputs
|
||||||
|
|||||||
181
.gitea/workflows/manual-deploy-major.yml
Normal file
181
.gitea/workflows/manual-deploy-major.yml
Normal file
@@ -0,0 +1,181 @@
|
|||||||
|
# .gitea/workflows/manual-deploy-major.yml
|
||||||
|
#
|
||||||
|
# This workflow provides a MANUAL trigger to perform a MAJOR version bump
|
||||||
|
# and deploy the application to the PRODUCTION environment.
|
||||||
|
name: Manual - Deploy Major Version to Production
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
confirmation:
|
||||||
|
description: 'Type "deploy-major-to-prod" to confirm you want to deploy a new major version.'
|
||||||
|
required: true
|
||||||
|
default: 'do-not-run'
|
||||||
|
force_reload:
|
||||||
|
description: 'Force PM2 reload even if version matches (true/false).'
|
||||||
|
required: false
|
||||||
|
type: boolean
|
||||||
|
default: false
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
deploy-production-major:
|
||||||
|
runs-on: projectium.com
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Verify Confirmation Phrase
|
||||||
|
run: |
|
||||||
|
if [ "${{ gitea.event.inputs.confirmation }}" != "deploy-major-to-prod" ]; then
|
||||||
|
echo "ERROR: Confirmation phrase did not match. Aborting deployment."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "✅ Confirmation accepted. Proceeding with major version production deployment."
|
||||||
|
|
||||||
|
- name: Checkout Code from 'main' branch
|
||||||
|
uses: actions/checkout@v3
|
||||||
|
with:
|
||||||
|
ref: 'main' # Explicitly check out the main branch for production deployment
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v3
|
||||||
|
with:
|
||||||
|
node-version: '20'
|
||||||
|
cache: 'npm'
|
||||||
|
cache-dependency-path: '**/package-lock.json'
|
||||||
|
|
||||||
|
- name: Install Dependencies
|
||||||
|
run: npm ci
|
||||||
|
|
||||||
|
- name: Bump Major Version and Push
|
||||||
|
run: |
|
||||||
|
# Configure git for the commit.
|
||||||
|
git config --global user.name 'Gitea Actions'
|
||||||
|
git config --global user.email 'actions@gitea.projectium.com'
|
||||||
|
|
||||||
|
# Bump the major version number. This creates a new commit and a new tag.
|
||||||
|
# The commit message includes [skip ci] to prevent this push from triggering another workflow run.
|
||||||
|
npm version major -m "ci: Bump version to %s for major release [skip ci]"
|
||||||
|
|
||||||
|
# Push the new commit and the new tag back to the main branch.
|
||||||
|
git push --follow-tags
|
||||||
|
|
||||||
|
- name: Check for Production Database Schema Changes
|
||||||
|
env:
|
||||||
|
DB_HOST: ${{ secrets.DB_HOST }}
|
||||||
|
DB_USER: ${{ secrets.DB_USER }}
|
||||||
|
DB_PASSWORD: ${{ secrets.DB_PASSWORD }}
|
||||||
|
DB_NAME: ${{ secrets.DB_DATABASE_PROD }}
|
||||||
|
run: |
|
||||||
|
if [ -z "$DB_HOST" ] || [ -z "$DB_USER" ] || [ -z "$DB_PASSWORD" ] || [ -z "$DB_NAME" ]; then
|
||||||
|
echo "ERROR: One or more production database secrets (DB_HOST, DB_USER, DB_PASSWORD, DB_DATABASE_PROD) are not set."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "--- Checking for production schema changes ---"
|
||||||
|
CURRENT_HASH=$(cat sql/master_schema_rollup.sql | dos2unix | sha256sum | awk '{ print $1 }')
|
||||||
|
echo "Current Git Schema Hash: $CURRENT_HASH"
|
||||||
|
# The psql command will now fail the step if the query errors (e.g., column missing), preventing deployment on a bad schema.
|
||||||
|
DEPLOYED_HASH=$(PGPASSWORD="$DB_PASSWORD" psql -v ON_ERROR_STOP=1 -h "$DB_HOST" -p 5432 -U "$DB_USER" -d "$DB_NAME" -c "SELECT schema_hash FROM public.schema_info WHERE environment = 'production';" -t -A)
|
||||||
|
echo "Deployed DB Schema Hash: $DEPLOYED_HASH"
|
||||||
|
if [ -z "$DEPLOYED_HASH" ]; then
|
||||||
|
echo "WARNING: No schema hash found in the production database. This is expected for a first-time deployment."
|
||||||
|
elif [ "$CURRENT_HASH" != "$DEPLOYED_HASH" ]; then
|
||||||
|
echo "ERROR: Database schema mismatch detected! A manual database migration is required."
|
||||||
|
exit 1
|
||||||
|
else
|
||||||
|
echo "✅ Schema is up to date. No changes detected."
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Build React Application for Production
|
||||||
|
run: |
|
||||||
|
if [ -z "${{ secrets.VITE_GOOGLE_GENAI_API_KEY }}" ]; then
|
||||||
|
echo "ERROR: The VITE_GOOGLE_GENAI_API_KEY secret is not set."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
GITEA_SERVER_URL="https://gitea.projectium.com"
|
||||||
|
COMMIT_MESSAGE=$(git log -1 --grep="\[skip ci\]" --invert-grep --pretty=%s)
|
||||||
|
PACKAGE_VERSION=$(node -p "require('./package.json').version")
|
||||||
|
VITE_APP_VERSION="$(date +'%Y%m%d-%H%M'):$(git rev-parse --short HEAD):$PACKAGE_VERSION" \
|
||||||
|
VITE_APP_COMMIT_URL="$GITEA_SERVER_URL/${{ gitea.repository }}/commit/${{ gitea.sha }}" \
|
||||||
|
VITE_APP_COMMIT_MESSAGE="$COMMIT_MESSAGE" \
|
||||||
|
VITE_API_BASE_URL=/api VITE_API_KEY=${{ secrets.VITE_GOOGLE_GENAI_API_KEY }} npm run build
|
||||||
|
|
||||||
|
- name: Deploy Application to Production Server
|
||||||
|
run: |
|
||||||
|
echo "Deploying application files to /var/www/flyer-crawler.projectium.com..."
|
||||||
|
APP_PATH="/var/www/flyer-crawler.projectium.com"
|
||||||
|
mkdir -p "$APP_PATH"
|
||||||
|
mkdir -p "$APP_PATH/flyer-images/icons" "$APP_PATH/flyer-images/archive"
|
||||||
|
rsync -avz --delete --exclude 'node_modules' --exclude '.git' --exclude 'dist' --exclude 'flyer-images' ./ "$APP_PATH/"
|
||||||
|
rsync -avz dist/ "$APP_PATH"
|
||||||
|
echo "Application deployment complete."
|
||||||
|
|
||||||
|
- name: Install Backend Dependencies and Restart Production Server
|
||||||
|
env:
|
||||||
|
# --- Production Secrets Injection ---
|
||||||
|
DB_HOST: ${{ secrets.DB_HOST }}
|
||||||
|
DB_USER: ${{ secrets.DB_USER }}
|
||||||
|
DB_PASSWORD: ${{ secrets.DB_PASSWORD }}
|
||||||
|
DB_NAME: ${{ secrets.DB_DATABASE_PROD }}
|
||||||
|
REDIS_URL: 'redis://localhost:6379'
|
||||||
|
REDIS_PASSWORD: ${{ secrets.REDIS_PASSWORD_PROD }}
|
||||||
|
FRONTEND_URL: 'https://flyer-crawler.projectium.com'
|
||||||
|
JWT_SECRET: ${{ secrets.JWT_SECRET }}
|
||||||
|
GEMINI_API_KEY: ${{ secrets.VITE_GOOGLE_GENAI_API_KEY }}
|
||||||
|
GOOGLE_MAPS_API_KEY: ${{ secrets.GOOGLE_MAPS_API_KEY }}
|
||||||
|
SMTP_HOST: 'localhost'
|
||||||
|
SMTP_PORT: '1025'
|
||||||
|
SMTP_SECURE: 'false'
|
||||||
|
SMTP_USER: ''
|
||||||
|
SMTP_PASS: ''
|
||||||
|
SMTP_FROM_EMAIL: 'noreply@flyer-crawler.projectium.com'
|
||||||
|
run: |
|
||||||
|
if [ -z "$DB_HOST" ] || [ -z "$DB_USER" ] || [ -z "$DB_PASSWORD" ] || [ -z "$DB_NAME" ]; then
|
||||||
|
echo "ERROR: One or more production database secrets (DB_HOST, DB_USER, DB_PASSWORD, DB_DATABASE_PROD) are not set."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "Installing production dependencies and restarting server..."
|
||||||
|
cd /var/www/flyer-crawler.projectium.com
|
||||||
|
npm install --omit=dev
|
||||||
|
|
||||||
|
# --- Version Check Logic ---
|
||||||
|
# Get the version from the newly deployed package.json
|
||||||
|
NEW_VERSION=$(node -p "require('./package.json').version")
|
||||||
|
echo "Deployed Package Version: $NEW_VERSION"
|
||||||
|
|
||||||
|
# Get the running version from PM2 for the main API process
|
||||||
|
# We use a small node script to parse the JSON output from pm2 jlist
|
||||||
|
RUNNING_VERSION=$(pm2 jlist | node -e "try { const list = JSON.parse(require('fs').readFileSync(0, 'utf-8')); const app = list.find(p => p.name === 'flyer-crawler-api'); console.log(app ? app.pm2_env.version : ''); } catch(e) { console.log(''); }")
|
||||||
|
echo "Running PM2 Version: $RUNNING_VERSION"
|
||||||
|
|
||||||
|
if [ "${{ gitea.event.inputs.force_reload }}" == "true" ] || [ "$NEW_VERSION" != "$RUNNING_VERSION" ] || [ -z "$RUNNING_VERSION" ]; then
|
||||||
|
if [ "${{ gitea.event.inputs.force_reload }}" == "true" ]; then
|
||||||
|
echo "Force reload triggered by manual input. Reloading PM2..."
|
||||||
|
else
|
||||||
|
echo "Version mismatch (Running: $RUNNING_VERSION -> Deployed: $NEW_VERSION) or app not running. Reloading PM2..."
|
||||||
|
fi
|
||||||
|
pm2 startOrReload ecosystem.config.cjs --env production && pm2 save
|
||||||
|
echo "Production backend server reloaded successfully."
|
||||||
|
else
|
||||||
|
echo "Version $NEW_VERSION is already running. Skipping PM2 reload."
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Updating schema hash in production database..."
|
||||||
|
CURRENT_HASH=$(cat sql/master_schema_rollup.sql | dos2unix | sha256sum | awk '{ print $1 }')
|
||||||
|
PGPASSWORD="$DB_PASSWORD" psql -v ON_ERROR_STOP=1 -h "$DB_HOST" -p 5432 -U "$DB_USER" -d "$DB_NAME" -c \
|
||||||
|
"INSERT INTO public.schema_info (environment, schema_hash, deployed_at) VALUES ('production', '$CURRENT_HASH', NOW())
|
||||||
|
ON CONFLICT (environment) DO UPDATE SET schema_hash = EXCLUDED.schema_hash, deployed_at = NOW();"
|
||||||
|
|
||||||
|
UPDATED_HASH=$(PGPASSWORD="$DB_PASSWORD" psql -v ON_ERROR_STOP=1 -h "$DB_HOST" -p 5432 -U "$DB_USER" -d "$DB_NAME" -c "SELECT schema_hash FROM public.schema_info WHERE environment = 'production';" -t -A)
|
||||||
|
if [ "$CURRENT_HASH" = "$UPDATED_HASH" ]; then
|
||||||
|
echo "✅ Schema hash successfully updated in the database to: $UPDATED_HASH"
|
||||||
|
else
|
||||||
|
echo "ERROR: Failed to update schema hash in the database."
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Show PM2 Environment for Production
|
||||||
|
run: |
|
||||||
|
echo "--- Displaying recent PM2 logs for flyer-crawler-api ---"
|
||||||
|
sleep 5
|
||||||
|
pm2 describe flyer-crawler-api || echo "Could not find production pm2 process."
|
||||||
|
pm2 logs flyer-crawler-api --lines 20 --nostream || echo "Could not find production pm2 process."
|
||||||
|
pm2 env flyer-crawler-api || echo "Could not find production pm2 process."
|
||||||
31
Dockerfile.dev
Normal file
31
Dockerfile.dev
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
# Use Ubuntu 22.04 (LTS) as the base image to match production
|
||||||
|
FROM ubuntu:22.04
|
||||||
|
|
||||||
|
# Set environment variables to non-interactive to avoid prompts during installation
|
||||||
|
ENV DEBIAN_FRONTEND=noninteractive
|
||||||
|
|
||||||
|
# Update package lists and install essential tools
|
||||||
|
# - curl: for downloading Node.js setup script
|
||||||
|
# - git: for version control operations
|
||||||
|
# - build-essential: for compiling native Node.js modules (node-gyp)
|
||||||
|
# - python3: required by some Node.js build tools
|
||||||
|
RUN apt-get update && apt-get install -y \
|
||||||
|
curl \
|
||||||
|
git \
|
||||||
|
build-essential \
|
||||||
|
python3 \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Install Node.js 20.x (LTS) from NodeSource
|
||||||
|
RUN curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \
|
||||||
|
&& apt-get install -y nodejs
|
||||||
|
|
||||||
|
# Set the working directory inside the container
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Set default environment variables for development
|
||||||
|
ENV NODE_ENV=development
|
||||||
|
ENV NODE_OPTIONS='--max-old-space-size=8192'
|
||||||
|
|
||||||
|
# Default command keeps the container running so you can attach to it
|
||||||
|
CMD ["bash"]
|
||||||
80
README.md
80
README.md
@@ -95,7 +95,7 @@ actually the proper change was to do this in the /etc/nginx/sites-available/flye
|
|||||||
## for OAuth
|
## for OAuth
|
||||||
|
|
||||||
1. Get Google OAuth Credentials
|
1. Get Google OAuth Credentials
|
||||||
This is a crucial step that you must do outside the codebase:
|
This is a crucial step that you must do outside the codebase:
|
||||||
|
|
||||||
Go to the Google Cloud Console.
|
Go to the Google Cloud Console.
|
||||||
|
|
||||||
@@ -112,7 +112,7 @@ Under Authorized redirect URIs, click ADD URI and enter the URL where Google wil
|
|||||||
Click Create. You will be given a Client ID and a Client Secret.
|
Click Create. You will be given a Client ID and a Client Secret.
|
||||||
|
|
||||||
2. Get GitHub OAuth Credentials
|
2. Get GitHub OAuth Credentials
|
||||||
You'll need to obtain a Client ID and Client Secret from GitHub:
|
You'll need to obtain a Client ID and Client Secret from GitHub:
|
||||||
|
|
||||||
Go to your GitHub profile settings.
|
Go to your GitHub profile settings.
|
||||||
|
|
||||||
@@ -133,21 +133,23 @@ You will be given a Client ID and a Client Secret.
|
|||||||
|
|
||||||
psql -h localhost -U flyer_crawler_user -d "flyer-crawler-prod" -W
|
psql -h localhost -U flyer_crawler_user -d "flyer-crawler-prod" -W
|
||||||
|
|
||||||
|
|
||||||
## postgis
|
## postgis
|
||||||
|
|
||||||
flyer-crawler-prod=> SELECT version();
|
flyer-crawler-prod=> SELECT version();
|
||||||
version
|
version
|
||||||
------------------------------------------------------------------------------------------------------------------------------------------
|
|
||||||
PostgreSQL 14.19 (Ubuntu 14.19-0ubuntu0.22.04.1) on x86_64-pc-linux-gnu, compiled by gcc (Ubuntu 11.4.0-1ubuntu1~22.04.2) 11.4.0, 64-bit
|
---
|
||||||
|
|
||||||
|
PostgreSQL 14.19 (Ubuntu 14.19-0ubuntu0.22.04.1) on x86_64-pc-linux-gnu, compiled by gcc (Ubuntu 11.4.0-1ubuntu1~22.04.2) 11.4.0, 64-bit
|
||||||
(1 row)
|
(1 row)
|
||||||
|
|
||||||
flyer-crawler-prod=> SELECT PostGIS_Full_Version();
|
flyer-crawler-prod=> SELECT PostGIS_Full_Version();
|
||||||
postgis_full_version
|
postgis_full_version
|
||||||
--------------------------------------------------------------------------------------------------------------------------------------------------------------------
|
|
||||||
POSTGIS="3.2.0 c3e3cc0" [EXTENSION] PGSQL="140" GEOS="3.10.2-CAPI-1.16.0" PROJ="8.2.1" LIBXML="2.9.12" LIBJSON="0.15" LIBPROTOBUF="1.3.3" WAGYU="0.5.0 (Internal)"
|
|
||||||
(1 row)
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
POSTGIS="3.2.0 c3e3cc0" [EXTENSION] PGSQL="140" GEOS="3.10.2-CAPI-1.16.0" PROJ="8.2.1" LIBXML="2.9.12" LIBJSON="0.15" LIBPROTOBUF="1.3.3" WAGYU="0.5.0 (Internal)"
|
||||||
|
(1 row)
|
||||||
|
|
||||||
## production postgres setup
|
## production postgres setup
|
||||||
|
|
||||||
@@ -201,9 +203,13 @@ Step 4: Seed the Admin Account (If Needed)
|
|||||||
Your application has a separate script to create the initial admin user. To run it, you must first set the required environment variables in your shell session.
|
Your application has a separate script to create the initial admin user. To run it, you must first set the required environment variables in your shell session.
|
||||||
|
|
||||||
bash
|
bash
|
||||||
|
|
||||||
# Set variables for the current session
|
# Set variables for the current session
|
||||||
|
|
||||||
export DB_USER=flyer_crawler_user DB_PASSWORD=your_password DB_NAME="flyer-crawler-prod" ...
|
export DB_USER=flyer_crawler_user DB_PASSWORD=your_password DB_NAME="flyer-crawler-prod" ...
|
||||||
|
|
||||||
# Run the seeding script
|
# Run the seeding script
|
||||||
|
|
||||||
npx tsx src/db/seed_admin_account.ts
|
npx tsx src/db/seed_admin_account.ts
|
||||||
Your production database is now ready!
|
Your production database is now ready!
|
||||||
|
|
||||||
@@ -284,8 +290,6 @@ Test Execution: Your tests run against this clean, isolated schema.
|
|||||||
|
|
||||||
This approach is faster, more reliable, and removes the need for sudo access within the CI pipeline.
|
This approach is faster, more reliable, and removes the need for sudo access within the CI pipeline.
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
gitea-runner@projectium:~$ pm2 install pm2-logrotate
|
gitea-runner@projectium:~$ pm2 install pm2-logrotate
|
||||||
[PM2][Module] Installing NPM pm2-logrotate module
|
[PM2][Module] Installing NPM pm2-logrotate module
|
||||||
[PM2][Module] Calling [NPM] to install pm2-logrotate ...
|
[PM2][Module] Calling [NPM] to install pm2-logrotate ...
|
||||||
@@ -293,7 +297,7 @@ gitea-runner@projectium:~$ pm2 install pm2-logrotate
|
|||||||
added 161 packages in 5s
|
added 161 packages in 5s
|
||||||
|
|
||||||
21 packages are looking for funding
|
21 packages are looking for funding
|
||||||
run `npm fund` for details
|
run `npm fund` for details
|
||||||
npm notice
|
npm notice
|
||||||
npm notice New patch version of npm available! 11.6.3 -> 11.6.4
|
npm notice New patch version of npm available! 11.6.3 -> 11.6.4
|
||||||
npm notice Changelog: https://github.com/npm/cli/releases/tag/v11.6.4
|
npm notice Changelog: https://github.com/npm/cli/releases/tag/v11.6.4
|
||||||
@@ -308,7 +312,7 @@ $ pm2 set pm2-logrotate:retain 30
|
|||||||
$ pm2 set pm2-logrotate:compress false
|
$ pm2 set pm2-logrotate:compress false
|
||||||
$ pm2 set pm2-logrotate:dateFormat YYYY-MM-DD_HH-mm-ss
|
$ pm2 set pm2-logrotate:dateFormat YYYY-MM-DD_HH-mm-ss
|
||||||
$ pm2 set pm2-logrotate:workerInterval 30
|
$ pm2 set pm2-logrotate:workerInterval 30
|
||||||
$ pm2 set pm2-logrotate:rotateInterval 0 0 * * *
|
$ pm2 set pm2-logrotate:rotateInterval 0 0 \* \* _
|
||||||
$ pm2 set pm2-logrotate:rotateModule true
|
$ pm2 set pm2-logrotate:rotateModule true
|
||||||
Modules configuration. Copy/Paste line to edit values.
|
Modules configuration. Copy/Paste line to edit values.
|
||||||
[PM2][Module] Module successfully installed and launched
|
[PM2][Module] Module successfully installed and launched
|
||||||
@@ -335,7 +339,7 @@ $ pm2 set pm2-logrotate:retain 30
|
|||||||
$ pm2 set pm2-logrotate:compress false
|
$ pm2 set pm2-logrotate:compress false
|
||||||
$ pm2 set pm2-logrotate:dateFormat YYYY-MM-DD_HH-mm-ss
|
$ pm2 set pm2-logrotate:dateFormat YYYY-MM-DD_HH-mm-ss
|
||||||
$ pm2 set pm2-logrotate:workerInterval 30
|
$ pm2 set pm2-logrotate:workerInterval 30
|
||||||
$ pm2 set pm2-logrotate:rotateInterval 0 0 * * *
|
$ pm2 set pm2-logrotate:rotateInterval 0 0 _ \* _
|
||||||
$ pm2 set pm2-logrotate:rotateModule true
|
$ pm2 set pm2-logrotate:rotateModule true
|
||||||
gitea-runner@projectium:~$ pm2 set pm2-logrotate:retain 14
|
gitea-runner@projectium:~$ pm2 set pm2-logrotate:retain 14
|
||||||
[PM2] Module pm2-logrotate restarted
|
[PM2] Module pm2-logrotate restarted
|
||||||
@@ -346,31 +350,29 @@ $ pm2 set pm2-logrotate:retain 14
|
|||||||
$ pm2 set pm2-logrotate:compress false
|
$ pm2 set pm2-logrotate:compress false
|
||||||
$ pm2 set pm2-logrotate:dateFormat YYYY-MM-DD_HH-mm-ss
|
$ pm2 set pm2-logrotate:dateFormat YYYY-MM-DD_HH-mm-ss
|
||||||
$ pm2 set pm2-logrotate:workerInterval 30
|
$ pm2 set pm2-logrotate:workerInterval 30
|
||||||
$ pm2 set pm2-logrotate:rotateInterval 0 0 * * *
|
$ pm2 set pm2-logrotate:rotateInterval 0 0 _ \* \*
|
||||||
$ pm2 set pm2-logrotate:rotateModule true
|
$ pm2 set pm2-logrotate:rotateModule true
|
||||||
gitea-runner@projectium:~$
|
gitea-runner@projectium:~$
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
## dev server setup:
|
## dev server setup:
|
||||||
|
|
||||||
Here are the steps to set up the development environment on Windows using Podman with an Ubuntu container:
|
Here are the steps to set up the development environment on Windows using Podman with an Ubuntu container:
|
||||||
|
|
||||||
1. Install Prerequisites on Windows
|
1. Install Prerequisites on Windows
|
||||||
Install WSL 2: Podman on Windows relies on the Windows Subsystem for Linux. Install it by running wsl --install in an administrator PowerShell.
|
Install WSL 2: Podman on Windows relies on the Windows Subsystem for Linux. Install it by running wsl --install in an administrator PowerShell.
|
||||||
Install Podman Desktop: Download and install Podman Desktop for Windows.
|
Install Podman Desktop: Download and install Podman Desktop for Windows.
|
||||||
|
|
||||||
2. Set Up Podman
|
2. Set Up Podman
|
||||||
Initialize Podman: Launch Podman Desktop. It will automatically set up its WSL 2 machine.
|
Initialize Podman: Launch Podman Desktop. It will automatically set up its WSL 2 machine.
|
||||||
Start Podman: Ensure the Podman machine is running from the Podman Desktop interface.
|
Start Podman: Ensure the Podman machine is running from the Podman Desktop interface.
|
||||||
|
|
||||||
3. Set Up the Ubuntu Container
|
3. Set Up the Ubuntu Container
|
||||||
- Pull Ubuntu Image: Open a PowerShell or command prompt and pull the latest Ubuntu image:
|
|
||||||
|
- Pull Ubuntu Image: Open a PowerShell or command prompt and pull the latest Ubuntu image:
|
||||||
podman pull ubuntu:latest
|
podman pull ubuntu:latest
|
||||||
- Create a Podman Volume: Create a volume to persist node_modules and avoid installing them every time the container starts.
|
- Create a Podman Volume: Create a volume to persist node_modules and avoid installing them every time the container starts.
|
||||||
podman volume create node_modules_cache
|
podman volume create node_modules_cache
|
||||||
- Run the Ubuntu Container: Start a new container with the project directory mounted and the necessary ports forwarded.
|
- Run the Ubuntu Container: Start a new container with the project directory mounted and the necessary ports forwarded.
|
||||||
- Open a terminal in your project's root directory on Windows.
|
- Open a terminal in your project's root directory on Windows.
|
||||||
- Run the following command, replacing D:\gitea\flyer-crawler.projectium.com\flyer-crawler.projectium.com with the full path to your project:
|
- Run the following command, replacing D:\gitea\flyer-crawler.projectium.com\flyer-crawler.projectium.com with the full path to your project:
|
||||||
|
|
||||||
@@ -385,16 +387,16 @@ podman run -it -p 3001:3001 -p 5173:5173 --name flyer-dev -v "D:\gitea\flyer-cra
|
|||||||
4. Configure the Ubuntu Environment
|
4. Configure the Ubuntu Environment
|
||||||
You are now inside the Ubuntu container's shell.
|
You are now inside the Ubuntu container's shell.
|
||||||
|
|
||||||
- Update Package Lists:
|
- Update Package Lists:
|
||||||
apt-get update
|
apt-get update
|
||||||
- Install Dependencies: Install curl, git, and nodejs (which includes npm).
|
- Install Dependencies: Install curl, git, and nodejs (which includes npm).
|
||||||
apt-get install -y curl git
|
apt-get install -y curl git
|
||||||
curl -sL https://deb.nodesource.com/setup_20.x | bash -
|
curl -sL https://deb.nodesource.com/setup_20.x | bash -
|
||||||
apt-get install -y nodejs
|
apt-get install -y nodejs
|
||||||
- Navigate to Project Directory:
|
- Navigate to Project Directory:
|
||||||
cd /app
|
cd /app
|
||||||
|
|
||||||
- Install Project Dependencies:
|
- Install Project Dependencies:
|
||||||
npm install
|
npm install
|
||||||
|
|
||||||
5. Run the Development Server
|
5. Run the Development Server
|
||||||
@@ -402,27 +404,21 @@ podman run -it -p 3001:3001 -p 5173:5173 --name flyer-dev -v "D:\gitea\flyer-cra
|
|||||||
npm run dev
|
npm run dev
|
||||||
|
|
||||||
6. Accessing the Application
|
6. Accessing the Application
|
||||||
- Frontend: Open your browser and go to http://localhost:5173.
|
|
||||||
- Backend: The frontend will make API calls to http://localhost:3001.
|
- Frontend: Open your browser and go to http://localhost:5173.
|
||||||
|
- Backend: The frontend will make API calls to http://localhost:3001.
|
||||||
|
|
||||||
Managing the Environment
|
Managing the Environment
|
||||||
- Stopping the Container: Press Ctrl+C in the container terminal, then type exit.
|
|
||||||
- Restarting the Container:
|
- Stopping the Container: Press Ctrl+C in the container terminal, then type exit.
|
||||||
|
- Restarting the Container:
|
||||||
podman start -a -i flyer-dev
|
podman start -a -i flyer-dev
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
## for me:
|
## for me:
|
||||||
|
|
||||||
cd /mnt/d/gitea/flyer-crawler.projectium.com/flyer-crawler.projectium.com
|
cd /mnt/d/gitea/flyer-crawler.projectium.com/flyer-crawler.projectium.com
|
||||||
podman run -it -p 3001:3001 -p 5173:5173 --name flyer-dev -v "$(pwd):/app" -v "node_modules_cache:/app/node_modules" ubuntu:latest
|
podman run -it -p 3001:3001 -p 5173:5173 --name flyer-dev -v "$(pwd):/app" -v "node_modules_cache:/app/node_modules" ubuntu:latest
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
rate limiting
|
rate limiting
|
||||||
|
|
||||||
respect the AI service's rate limits, making it more stable and robust. You can adjust the GEMINI_RPM environment variable in your production environment as needed without changing the code.
|
respect the AI service's rate limits, making it more stable and robust. You can adjust the GEMINI_RPM environment variable in your production environment as needed without changing the code.
|
||||||
52
compose.dev.yml
Normal file
52
compose.dev.yml
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
version: '3.8'
|
||||||
|
|
||||||
|
services:
|
||||||
|
app:
|
||||||
|
container_name: flyer-crawler-dev
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile.dev
|
||||||
|
volumes:
|
||||||
|
# Mount the current directory to /app in the container
|
||||||
|
- .:/app
|
||||||
|
# Create a volume for node_modules to avoid conflicts with Windows host
|
||||||
|
# and improve performance.
|
||||||
|
- node_modules_data:/app/node_modules
|
||||||
|
ports:
|
||||||
|
- '3000:3000' # Frontend (Vite default)
|
||||||
|
- '3001:3001' # Backend API
|
||||||
|
environment:
|
||||||
|
- NODE_ENV=development
|
||||||
|
- DB_HOST=postgres
|
||||||
|
- DB_USER=postgres
|
||||||
|
- DB_PASSWORD=postgres
|
||||||
|
- DB_NAME=flyer_crawler_dev
|
||||||
|
- REDIS_URL=redis://redis:6379
|
||||||
|
# Add other secrets here or use a .env file
|
||||||
|
depends_on:
|
||||||
|
- postgres
|
||||||
|
- redis
|
||||||
|
# Keep container running so VS Code can attach
|
||||||
|
command: tail -f /dev/null
|
||||||
|
|
||||||
|
postgres:
|
||||||
|
image: docker.io/library/postgis/postgis:15-3.4
|
||||||
|
container_name: flyer-crawler-postgres
|
||||||
|
ports:
|
||||||
|
- '5432:5432'
|
||||||
|
environment:
|
||||||
|
POSTGRES_USER: postgres
|
||||||
|
POSTGRES_PASSWORD: postgres
|
||||||
|
POSTGRES_DB: flyer_crawler_dev
|
||||||
|
volumes:
|
||||||
|
- postgres_data:/var/lib/postgresql/data
|
||||||
|
|
||||||
|
redis:
|
||||||
|
image: docker.io/library/redis:alpine
|
||||||
|
container_name: flyer-crawler-redis
|
||||||
|
ports:
|
||||||
|
- '6379:6379'
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
postgres_data:
|
||||||
|
node_modules_data:
|
||||||
@@ -34,7 +34,7 @@ We will adopt a strict, consistent error-handling contract for the service and r
|
|||||||
**Robustness**: Eliminates an entire class of bugs where `undefined` is passed to `res.json()`, preventing incorrect `500` errors.
|
**Robustness**: Eliminates an entire class of bugs where `undefined` is passed to `res.json()`, preventing incorrect `500` errors.
|
||||||
**Consistency & Predictability**: All data-fetching methods now have a predictable contract. They either return the expected data or throw a specific, typed error.
|
**Consistency & Predictability**: All data-fetching methods now have a predictable contract. They either return the expected data or throw a specific, typed error.
|
||||||
**Developer Experience**: Route handlers become simpler, cleaner, and easier to write correctly. The cognitive load on developers is reduced as they no longer need to remember to check for `undefined`.
|
**Developer Experience**: Route handlers become simpler, cleaner, and easier to write correctly. The cognitive load on developers is reduced as they no longer need to remember to check for `undefined`.
|
||||||
**Improved Testability**: Tests become more reliable and realistic. Mocks can now throw the *exact* error type (`new NotFoundError()`) that the real implementation would, ensuring tests accurately reflect the application's behavior.
|
**Improved Testability**: Tests become more reliable and realistic. Mocks can now throw the _exact_ error type (`new NotFoundError()`) that the real implementation would, ensuring tests accurately reflect the application's behavior.
|
||||||
**Centralized Control**: Error-to-HTTP-status logic is centralized in the `errorHandler` middleware, making it easy to manage and modify error responses globally.
|
**Centralized Control**: Error-to-HTTP-status logic is centralized in the `errorHandler` middleware, making it easy to manage and modify error responses globally.
|
||||||
|
|
||||||
### Negative
|
### Negative
|
||||||
|
|||||||
@@ -10,21 +10,19 @@ Following the standardization of error handling in ADR-001, the next most common
|
|||||||
|
|
||||||
This manual approach has several drawbacks:
|
This manual approach has several drawbacks:
|
||||||
**Repetitive Boilerplate**: The `try/catch/finally` block for transaction management is duplicated across multiple files.
|
**Repetitive Boilerplate**: The `try/catch/finally` block for transaction management is duplicated across multiple files.
|
||||||
**Error-Prone**: It is easy to forget to `client.release()` in all code paths, which can lead to connection pool exhaustion and bring down the application.
|
**Error-Prone**: It is easy to forget to `client.release()` in all code paths, which can lead to connection pool exhaustion and bring down the application. 3. **Poor Composability**: It is difficult to compose multiple repository methods into a single, atomic "Unit of Work". For example, a service function that needs to update a user's points and create a budget in a single transaction cannot easily do so if both underlying repository methods create their own transactions.
|
||||||
3. **Poor Composability**: It is difficult to compose multiple repository methods into a single, atomic "Unit of Work". For example, a service function that needs to update a user's points and create a budget in a single transaction cannot easily do so if both underlying repository methods create their own transactions.
|
|
||||||
|
|
||||||
## Decision
|
## Decision
|
||||||
|
|
||||||
We will implement a standardized "Unit of Work" pattern through a high-level `withTransaction` helper function. This function will abstract away the complexity of transaction management.
|
We will implement a standardized "Unit of Work" pattern through a high-level `withTransaction` helper function. This function will abstract away the complexity of transaction management.
|
||||||
|
|
||||||
1. **`withTransaction` Helper**: A new helper function, `withTransaction<T>(callback: (client: PoolClient) => Promise<T>): Promise<T>`, will be created. This function will be responsible for:
|
1. **`withTransaction` Helper**: A new helper function, `withTransaction<T>(callback: (client: PoolClient) => Promise<T>): Promise<T>`, will be created. This function will be responsible for:
|
||||||
|
- Acquiring a client from the database pool.
|
||||||
* Acquiring a client from the database pool.
|
- Starting a transaction (`BEGIN`).
|
||||||
* Starting a transaction (`BEGIN`).
|
- Executing the `callback` function, passing the transactional client to it.
|
||||||
* Executing the `callback` function, passing the transactional client to it.
|
- If the callback succeeds, it will `COMMIT` the transaction.
|
||||||
* If the callback succeeds, it will `COMMIT` the transaction.
|
- If the callback throws an error, it will `ROLLBACK` the transaction and re-throw the error.
|
||||||
* If the callback throws an error, it will `ROLLBACK` the transaction and re-throw the error.
|
- In all cases, it will `RELEASE` the client back to the pool.
|
||||||
* In all cases, it will `RELEASE` the client back to the pool.
|
|
||||||
|
|
||||||
2. **Repository Method Signature**: Repository methods that need to be part of a transaction will be updated to optionally accept a `PoolClient` in their constructor or as a method parameter. By default, they will use the global pool. When called from within a `withTransaction` block, they will be passed the transactional client.
|
2. **Repository Method Signature**: Repository methods that need to be part of a transaction will be updated to optionally accept a `PoolClient` in their constructor or as a method parameter. By default, they will use the global pool. When called from within a `withTransaction` block, they will be passed the transactional client.
|
||||||
3. **Service Layer Orchestration**: Service-layer functions that orchestrate multi-step operations will use `withTransaction` to ensure atomicity. They will instantiate or call repository methods, providing them with the transactional client from the callback.
|
3. **Service Layer Orchestration**: Service-layer functions that orchestrate multi-step operations will use `withTransaction` to ensure atomicity. They will instantiate or call repository methods, providing them with the transactional client from the callback.
|
||||||
@@ -40,7 +38,7 @@ async function registerUserAndCreateDefaultList(userData) {
|
|||||||
const shoppingRepo = new ShoppingRepository(client);
|
const shoppingRepo = new ShoppingRepository(client);
|
||||||
|
|
||||||
const newUser = await userRepo.createUser(userData);
|
const newUser = await userRepo.createUser(userData);
|
||||||
await shoppingRepo.createShoppingList(newUser.user_id, "My First List");
|
await shoppingRepo.createShoppingList(newUser.user_id, 'My First List');
|
||||||
|
|
||||||
return newUser;
|
return newUser;
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -20,8 +20,8 @@ We will adopt a schema-based approach for input validation using the `zod` libra
|
|||||||
1. **Adopt `zod` for Schema Definition**: We will use `zod` to define clear, type-safe schemas for the `params`, `query`, and `body` of each API request. `zod` provides powerful and declarative validation rules and automatically infers TypeScript types.
|
1. **Adopt `zod` for Schema Definition**: We will use `zod` to define clear, type-safe schemas for the `params`, `query`, and `body` of each API request. `zod` provides powerful and declarative validation rules and automatically infers TypeScript types.
|
||||||
|
|
||||||
2. **Create a Reusable Validation Middleware**: A generic `validateRequest(schema)` middleware will be created. This middleware will take a `zod` schema, parse the incoming request against it, and handle success and error cases.
|
2. **Create a Reusable Validation Middleware**: A generic `validateRequest(schema)` middleware will be created. This middleware will take a `zod` schema, parse the incoming request against it, and handle success and error cases.
|
||||||
* On successful validation, the parsed and typed data will be attached to the `req` object (e.g., `req.body` will be replaced with the parsed body), and `next()` will be called.
|
- On successful validation, the parsed and typed data will be attached to the `req` object (e.g., `req.body` will be replaced with the parsed body), and `next()` will be called.
|
||||||
* On validation failure, the middleware will call `next()` with a custom `ValidationError` containing a structured list of issues, which `ADR-001`'s `errorHandler` can then format into a user-friendly `400 Bad Request` response.
|
- On validation failure, the middleware will call `next()` with a custom `ValidationError` containing a structured list of issues, which `ADR-001`'s `errorHandler` can then format into a user-friendly `400 Bad Request` response.
|
||||||
|
|
||||||
3. **Refactor Routes**: All route handlers will be refactored to use this new middleware, removing all manual validation logic.
|
3. **Refactor Routes**: All route handlers will be refactored to use this new middleware, removing all manual validation logic.
|
||||||
|
|
||||||
@@ -46,7 +46,7 @@ const getFlyerSchema = z.object({
|
|||||||
type GetFlyerRequest = z.infer<typeof getFlyerSchema>;
|
type GetFlyerRequest = z.infer<typeof getFlyerSchema>;
|
||||||
|
|
||||||
// 3. Apply the middleware and use an inline cast for the request
|
// 3. Apply the middleware and use an inline cast for the request
|
||||||
router.get('/:id', validateRequest(getFlyerSchema), (async (req, res, next) => {
|
router.get('/:id', validateRequest(getFlyerSchema), async (req, res, next) => {
|
||||||
// Cast 'req' to the inferred type.
|
// Cast 'req' to the inferred type.
|
||||||
// This provides full type safety for params, query, and body.
|
// This provides full type safety for params, query, and body.
|
||||||
const { params } = req as unknown as GetFlyerRequest;
|
const { params } = req as unknown as GetFlyerRequest;
|
||||||
@@ -57,7 +57,7 @@ router.get('/:id', validateRequest(getFlyerSchema), (async (req, res, next) => {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
next(error);
|
next(error);
|
||||||
}
|
}
|
||||||
}));
|
});
|
||||||
```
|
```
|
||||||
|
|
||||||
## Consequences
|
## Consequences
|
||||||
|
|||||||
@@ -20,9 +20,9 @@ We will adopt a standardized, application-wide structured logging policy. All lo
|
|||||||
|
|
||||||
**Request-Scoped Logger with Context**: We will create a middleware that runs at the beginning of the request lifecycle. This middleware will:
|
**Request-Scoped Logger with Context**: We will create a middleware that runs at the beginning of the request lifecycle. This middleware will:
|
||||||
|
|
||||||
* Generate a unique `request_id` for each incoming request.
|
- Generate a unique `request_id` for each incoming request.
|
||||||
* Create a request-scoped logger instance (a "child logger") that automatically includes the `request_id`, `user_id` (if authenticated), and `ip_address` in every log message it generates.
|
- Create a request-scoped logger instance (a "child logger") that automatically includes the `request_id`, `user_id` (if authenticated), and `ip_address` in every log message it generates.
|
||||||
* Attach this child logger to the `req` object (e.g., `req.log`).
|
- Attach this child logger to the `req` object (e.g., `req.log`).
|
||||||
|
|
||||||
**Mandatory Use of Request-Scoped Logger**: All route handlers and any service functions called by them **MUST** use the request-scoped logger (`req.log`) instead of the global logger instance. This ensures all logs for a given request are automatically correlated.
|
**Mandatory Use of Request-Scoped Logger**: All route handlers and any service functions called by them **MUST** use the request-scoped logger (`req.log`) instead of the global logger instance. This ensures all logs for a given request are automatically correlated.
|
||||||
|
|
||||||
@@ -32,9 +32,9 @@ We will adopt a standardized, application-wide structured logging policy. All lo
|
|||||||
|
|
||||||
**Standardized Logging Practices**:
|
**Standardized Logging Practices**:
|
||||||
**INFO**: Log key business events, such as `User logged in` or `Flyer processed`.
|
**INFO**: Log key business events, such as `User logged in` or `Flyer processed`.
|
||||||
**WARN**: Log recoverable errors or unusual situations that do not break the request, such as `Client Error: 404 on GET /api/non-existent-route` or `Retrying failed database connection`.
|
**WARN**: Log recoverable errors or unusual situations that do not break the request, such as `Client Error: 404 on GET /api/non-existent-route` or `Retrying failed database connection`.
|
||||||
**ERROR**: Log only unhandled or server-side errors that cause a request to fail (typically handled by the `errorHandler`). Avoid logging expected client errors (like 4xx) at this level.
|
**ERROR**: Log only unhandled or server-side errors that cause a request to fail (typically handled by the `errorHandler`). Avoid logging expected client errors (like 4xx) at this level.
|
||||||
**DEBUG**: Log detailed diagnostic information useful during development, such as function entry/exit points or variable states.
|
**DEBUG**: Log detailed diagnostic information useful during development, such as function entry/exit points or variable states.
|
||||||
|
|
||||||
### Example Usage
|
### Example Usage
|
||||||
|
|
||||||
|
|||||||
@@ -14,5 +14,5 @@ We will formalize a centralized Role-Based Access Control (RBAC) or Attribute-Ba
|
|||||||
|
|
||||||
## Consequences
|
## Consequences
|
||||||
|
|
||||||
* **Positive**: Ensures authorization logic is consistent, easy to audit, and decoupled from business logic. Improves security by centralizing access control.
|
- **Positive**: Ensures authorization logic is consistent, easy to audit, and decoupled from business logic. Improves security by centralizing access control.
|
||||||
* **Negative**: Requires a significant refactoring effort to integrate the new authorization system across all protected routes and features. Introduces a new dependency if an external library is chosen.
|
- **Negative**: Requires a significant refactoring effort to integrate the new authorization system across all protected routes and features. Introduces a new dependency if an external library is chosen.
|
||||||
|
|||||||
@@ -14,5 +14,5 @@ We will establish a formal Design System and Component Library. This will involv
|
|||||||
|
|
||||||
## Consequences
|
## Consequences
|
||||||
|
|
||||||
* **Positive**: Ensures a consistent and high-quality user interface. Accelerates frontend development by providing reusable, well-documented components. Improves maintainability and reduces technical debt.
|
- **Positive**: Ensures a consistent and high-quality user interface. Accelerates frontend development by providing reusable, well-documented components. Improves maintainability and reduces technical debt.
|
||||||
* **Negative**: Requires an initial investment in setting up Storybook and migrating existing components. Adds a new dependency and a new workflow for frontend development.
|
- **Negative**: Requires an initial investment in setting up Storybook and migrating existing components. Adds a new dependency and a new workflow for frontend development.
|
||||||
|
|||||||
@@ -14,5 +14,5 @@ We will adopt a dedicated database migration tool, such as **`node-pg-migrate`**
|
|||||||
|
|
||||||
## Consequences
|
## Consequences
|
||||||
|
|
||||||
* **Positive**: Provides a safe, repeatable, and reversible way to evolve the database schema. Improves team collaboration on database changes. Reduces the risk of data loss or downtime during deployments.
|
- **Positive**: Provides a safe, repeatable, and reversible way to evolve the database schema. Improves team collaboration on database changes. Reduces the risk of data loss or downtime during deployments.
|
||||||
* **Negative**: Requires an initial setup and learning curve for the chosen migration tool. All future schema changes must adhere to the migration workflow.
|
- **Negative**: Requires an initial setup and learning curve for the chosen migration tool. All future schema changes must adhere to the migration workflow.
|
||||||
|
|||||||
@@ -14,5 +14,5 @@ We will standardize the deployment process by containerizing the application usi
|
|||||||
|
|
||||||
## Consequences
|
## Consequences
|
||||||
|
|
||||||
* **Positive**: Ensures consistency between development and production environments. Simplifies the setup for new developers. Improves portability and scalability of the application.
|
- **Positive**: Ensures consistency between development and production environments. Simplifies the setup for new developers. Improves portability and scalability of the application.
|
||||||
* **Negative**: Requires learning Docker and containerization concepts. Adds `Dockerfile` and `docker-compose.yml` to the project's configuration.
|
- **Negative**: Requires learning Docker and containerization concepts. Adds `Dockerfile` and `docker-compose.yml` to the project's configuration.
|
||||||
|
|||||||
@@ -18,5 +18,5 @@ We will implement a multi-layered security approach for the API:
|
|||||||
|
|
||||||
## Consequences
|
## Consequences
|
||||||
|
|
||||||
* **Positive**: Significantly improves the application's security posture against common web vulnerabilities like XSS, clickjacking, and brute-force attacks.
|
- **Positive**: Significantly improves the application's security posture against common web vulnerabilities like XSS, clickjacking, and brute-force attacks.
|
||||||
* **Negative**: Requires careful configuration of CORS and rate limits to avoid blocking legitimate traffic. Content-Security-Policy can be complex to configure correctly.
|
- **Negative**: Requires careful configuration of CORS and rate limits to avoid blocking legitimate traffic. Content-Security-Policy can be complex to configure correctly.
|
||||||
|
|||||||
@@ -14,5 +14,5 @@ We will formalize the end-to-end CI/CD process. This ADR will define the project
|
|||||||
|
|
||||||
## Consequences
|
## Consequences
|
||||||
|
|
||||||
* **Positive**: Automates quality control and creates a safe, repeatable path to production. Increases development velocity and reduces deployment-related errors.
|
- **Positive**: Automates quality control and creates a safe, repeatable path to production. Increases development velocity and reduces deployment-related errors.
|
||||||
* **Negative**: Initial setup effort for the CI/CD pipeline. May slightly increase the time to merge code due to mandatory checks.
|
- **Negative**: Initial setup effort for the CI/CD pipeline. May slightly increase the time to merge code due to mandatory checks.
|
||||||
|
|||||||
@@ -14,5 +14,5 @@ We will adopt **OpenAPI (Swagger)** for API documentation. We will use tools (e.
|
|||||||
|
|
||||||
## Consequences
|
## Consequences
|
||||||
|
|
||||||
* **Positive**: Creates a single source of truth for API documentation that stays in sync with the code. Enables auto-generation of client SDKs and simplifies testing.
|
- **Positive**: Creates a single source of truth for API documentation that stays in sync with the code. Enables auto-generation of client SDKs and simplifies testing.
|
||||||
* **Negative**: Requires developers to maintain JSDoc annotations on all routes. Adds a build step and new dependencies to the project.
|
- **Negative**: Requires developers to maintain JSDoc annotations on all routes. Adds a build step and new dependencies to the project.
|
||||||
|
|||||||
@@ -14,5 +14,5 @@ We will implement a formal data backup and recovery strategy. This will involve
|
|||||||
|
|
||||||
## Consequences
|
## Consequences
|
||||||
|
|
||||||
* **Positive**: Protects against catastrophic data loss, ensuring business continuity. Provides a clear, tested plan for disaster recovery.
|
- **Positive**: Protects against catastrophic data loss, ensuring business continuity. Provides a clear, tested plan for disaster recovery.
|
||||||
* **Negative**: Requires setup and maintenance of backup scripts and secure storage. Incurs storage costs for backup files.
|
- **Negative**: Requires setup and maintenance of backup scripts and secure storage. Incurs storage costs for backup files.
|
||||||
|
|||||||
@@ -12,11 +12,11 @@ When the application is containerized (`ADR-014`), the container orchestrator (e
|
|||||||
|
|
||||||
We will implement dedicated health check endpoints in the Express application.
|
We will implement dedicated health check endpoints in the Express application.
|
||||||
|
|
||||||
* A **Liveness Probe** (`/api/health/live`) will return a `200 OK` to indicate the server is running. If it fails, the orchestrator should restart the container.
|
- A **Liveness Probe** (`/api/health/live`) will return a `200 OK` to indicate the server is running. If it fails, the orchestrator should restart the container.
|
||||||
|
|
||||||
* A **Readiness Probe** (`/api/health/ready`) will return a `200 OK` only if the application is ready to accept traffic (e.g., database connection is established). If it fails, the orchestrator will temporarily remove the container from the load balancer.
|
- A **Readiness Probe** (`/api/health/ready`) will return a `200 OK` only if the application is ready to accept traffic (e.g., database connection is established). If it fails, the orchestrator will temporarily remove the container from the load balancer.
|
||||||
|
|
||||||
## Consequences
|
## Consequences
|
||||||
|
|
||||||
* **Positive**: Enables robust, automated application lifecycle management in a containerized environment. Prevents traffic from being sent to unhealthy or uninitialized application instances.
|
- **Positive**: Enables robust, automated application lifecycle management in a containerized environment. Prevents traffic from being sent to unhealthy or uninitialized application instances.
|
||||||
* **Negative**: Adds a small amount of code for the health check endpoints. Requires configuration in the container orchestration layer.
|
- **Negative**: Adds a small amount of code for the health check endpoints. Requires configuration in the container orchestration layer.
|
||||||
|
|||||||
@@ -24,8 +24,8 @@ We will adopt a standardized, application-wide structured logging policy for all
|
|||||||
|
|
||||||
**2. Pino-like API for Structured Logging**: The client logger mimics the `pino` API, which is the standard on the backend. It supports two primary call signatures:
|
**2. Pino-like API for Structured Logging**: The client logger mimics the `pino` API, which is the standard on the backend. It supports two primary call signatures:
|
||||||
|
|
||||||
* `logger.info('A simple message');`
|
- `logger.info('A simple message');`
|
||||||
* `logger.info({ key: 'value' }, 'A message with a structured data payload');`
|
- `logger.info({ key: 'value' }, 'A message with a structured data payload');`
|
||||||
|
|
||||||
The second signature, which includes a data object as the first argument, is **strongly preferred**, especially for logging errors or complex state.
|
The second signature, which includes a data object as the first argument, is **strongly preferred**, especially for logging errors or complex state.
|
||||||
|
|
||||||
@@ -79,7 +79,7 @@ describe('MyComponent', () => {
|
|||||||
// Assert that the logger was called with the expected structure
|
// Assert that the logger was called with the expected structure
|
||||||
expect(logger.error).toHaveBeenCalledWith(
|
expect(logger.error).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({ err: expect.any(Error) }), // Check for the error object
|
expect.objectContaining({ err: expect.any(Error) }), // Check for the error object
|
||||||
'Failed to fetch component data' // Check for the message
|
'Failed to fetch component data', // Check for the message
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -18,48 +18,222 @@ module.exports = {
|
|||||||
NODE_ENV: 'production', // Set the Node.js environment to production
|
NODE_ENV: 'production', // Set the Node.js environment to production
|
||||||
name: 'flyer-crawler-api',
|
name: 'flyer-crawler-api',
|
||||||
cwd: '/var/www/flyer-crawler.projectium.com',
|
cwd: '/var/www/flyer-crawler.projectium.com',
|
||||||
|
// Inherit secrets from the deployment environment
|
||||||
|
DB_HOST: process.env.DB_HOST,
|
||||||
|
DB_USER: process.env.DB_USER,
|
||||||
|
DB_PASSWORD: process.env.DB_PASSWORD,
|
||||||
|
DB_NAME: process.env.DB_NAME,
|
||||||
|
REDIS_URL: process.env.REDIS_URL,
|
||||||
|
REDIS_PASSWORD: process.env.REDIS_PASSWORD,
|
||||||
|
FRONTEND_URL: process.env.FRONTEND_URL,
|
||||||
|
JWT_SECRET: process.env.JWT_SECRET,
|
||||||
|
GEMINI_API_KEY: process.env.GEMINI_API_KEY,
|
||||||
|
GOOGLE_MAPS_API_KEY: process.env.GOOGLE_MAPS_API_KEY,
|
||||||
|
SMTP_HOST: process.env.SMTP_HOST,
|
||||||
|
SMTP_PORT: process.env.SMTP_PORT,
|
||||||
|
SMTP_SECURE: process.env.SMTP_SECURE,
|
||||||
|
SMTP_USER: process.env.SMTP_USER,
|
||||||
|
SMTP_PASS: process.env.SMTP_PASS,
|
||||||
|
SMTP_FROM_EMAIL: process.env.SMTP_FROM_EMAIL,
|
||||||
},
|
},
|
||||||
// Test Environment Settings
|
// Test Environment Settings
|
||||||
env_test: {
|
env_test: {
|
||||||
NODE_ENV: 'development', // Use 'development' for test to enable more verbose logging if needed
|
NODE_ENV: 'test', // Set to 'test' to match the environment purpose and disable pino-pretty
|
||||||
name: 'flyer-crawler-api-test',
|
name: 'flyer-crawler-api-test',
|
||||||
cwd: '/var/www/flyer-crawler-test.projectium.com',
|
cwd: '/var/www/flyer-crawler-test.projectium.com',
|
||||||
|
// Inherit secrets from the deployment environment
|
||||||
|
DB_HOST: process.env.DB_HOST,
|
||||||
|
DB_USER: process.env.DB_USER,
|
||||||
|
DB_PASSWORD: process.env.DB_PASSWORD,
|
||||||
|
DB_NAME: process.env.DB_NAME,
|
||||||
|
REDIS_URL: process.env.REDIS_URL,
|
||||||
|
REDIS_PASSWORD: process.env.REDIS_PASSWORD,
|
||||||
|
FRONTEND_URL: process.env.FRONTEND_URL,
|
||||||
|
JWT_SECRET: process.env.JWT_SECRET,
|
||||||
|
GEMINI_API_KEY: process.env.GEMINI_API_KEY,
|
||||||
|
GOOGLE_MAPS_API_KEY: process.env.GOOGLE_MAPS_API_KEY,
|
||||||
|
SMTP_HOST: process.env.SMTP_HOST,
|
||||||
|
SMTP_PORT: process.env.SMTP_PORT,
|
||||||
|
SMTP_SECURE: process.env.SMTP_SECURE,
|
||||||
|
SMTP_USER: process.env.SMTP_USER,
|
||||||
|
SMTP_PASS: process.env.SMTP_PASS,
|
||||||
|
SMTP_FROM_EMAIL: process.env.SMTP_FROM_EMAIL,
|
||||||
|
},
|
||||||
|
// Development Environment Settings
|
||||||
|
env_development: {
|
||||||
|
NODE_ENV: 'development',
|
||||||
|
name: 'flyer-crawler-api-dev',
|
||||||
|
watch: true,
|
||||||
|
ignore_watch: ['node_modules', 'logs', '*.log', 'flyer-images', '.git'],
|
||||||
|
// Inherit secrets from the deployment environment
|
||||||
|
DB_HOST: process.env.DB_HOST,
|
||||||
|
DB_USER: process.env.DB_USER,
|
||||||
|
DB_PASSWORD: process.env.DB_PASSWORD,
|
||||||
|
DB_NAME: process.env.DB_NAME,
|
||||||
|
REDIS_URL: process.env.REDIS_URL,
|
||||||
|
REDIS_PASSWORD: process.env.REDIS_PASSWORD,
|
||||||
|
FRONTEND_URL: process.env.FRONTEND_URL,
|
||||||
|
JWT_SECRET: process.env.JWT_SECRET,
|
||||||
|
GEMINI_API_KEY: process.env.GEMINI_API_KEY,
|
||||||
|
GOOGLE_MAPS_API_KEY: process.env.GOOGLE_MAPS_API_KEY,
|
||||||
|
SMTP_HOST: process.env.SMTP_HOST,
|
||||||
|
SMTP_PORT: process.env.SMTP_PORT,
|
||||||
|
SMTP_SECURE: process.env.SMTP_SECURE,
|
||||||
|
SMTP_USER: process.env.SMTP_USER,
|
||||||
|
SMTP_PASS: process.env.SMTP_PASS,
|
||||||
|
SMTP_FROM_EMAIL: process.env.SMTP_FROM_EMAIL,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
// --- General Worker ---
|
// --- General Worker ---
|
||||||
name: 'flyer-crawler-worker',
|
name: 'flyer-crawler-worker',
|
||||||
script: './node_modules/.bin/tsx',
|
script: './node_modules/.bin/tsx',
|
||||||
args: 'src/services/queueService.server.ts', // tsx will execute this file
|
args: 'src/services/worker.ts', // tsx will execute this file
|
||||||
// Production Environment Settings
|
// Production Environment Settings
|
||||||
env_production: {
|
env_production: {
|
||||||
NODE_ENV: 'production',
|
NODE_ENV: 'production',
|
||||||
name: 'flyer-crawler-worker',
|
name: 'flyer-crawler-worker',
|
||||||
cwd: '/var/www/flyer-crawler.projectium.com',
|
cwd: '/var/www/flyer-crawler.projectium.com',
|
||||||
|
// Inherit secrets from the deployment environment
|
||||||
|
DB_HOST: process.env.DB_HOST,
|
||||||
|
DB_USER: process.env.DB_USER,
|
||||||
|
DB_PASSWORD: process.env.DB_PASSWORD,
|
||||||
|
DB_NAME: process.env.DB_NAME,
|
||||||
|
REDIS_URL: process.env.REDIS_URL,
|
||||||
|
REDIS_PASSWORD: process.env.REDIS_PASSWORD,
|
||||||
|
FRONTEND_URL: process.env.FRONTEND_URL,
|
||||||
|
JWT_SECRET: process.env.JWT_SECRET,
|
||||||
|
GEMINI_API_KEY: process.env.GEMINI_API_KEY,
|
||||||
|
GOOGLE_MAPS_API_KEY: process.env.GOOGLE_MAPS_API_KEY,
|
||||||
|
SMTP_HOST: process.env.SMTP_HOST,
|
||||||
|
SMTP_PORT: process.env.SMTP_PORT,
|
||||||
|
SMTP_SECURE: process.env.SMTP_SECURE,
|
||||||
|
SMTP_USER: process.env.SMTP_USER,
|
||||||
|
SMTP_PASS: process.env.SMTP_PASS,
|
||||||
|
SMTP_FROM_EMAIL: process.env.SMTP_FROM_EMAIL,
|
||||||
},
|
},
|
||||||
// Test Environment Settings
|
// Test Environment Settings
|
||||||
env_test: {
|
env_test: {
|
||||||
NODE_ENV: 'development',
|
NODE_ENV: 'test',
|
||||||
name: 'flyer-crawler-worker-test',
|
name: 'flyer-crawler-worker-test',
|
||||||
cwd: '/var/www/flyer-crawler-test.projectium.com',
|
cwd: '/var/www/flyer-crawler-test.projectium.com',
|
||||||
|
// Inherit secrets from the deployment environment
|
||||||
|
DB_HOST: process.env.DB_HOST,
|
||||||
|
DB_USER: process.env.DB_USER,
|
||||||
|
DB_PASSWORD: process.env.DB_PASSWORD,
|
||||||
|
DB_NAME: process.env.DB_NAME,
|
||||||
|
REDIS_URL: process.env.REDIS_URL,
|
||||||
|
REDIS_PASSWORD: process.env.REDIS_PASSWORD,
|
||||||
|
FRONTEND_URL: process.env.FRONTEND_URL,
|
||||||
|
JWT_SECRET: process.env.JWT_SECRET,
|
||||||
|
GEMINI_API_KEY: process.env.GEMINI_API_KEY,
|
||||||
|
GOOGLE_MAPS_API_KEY: process.env.GOOGLE_MAPS_API_KEY,
|
||||||
|
SMTP_HOST: process.env.SMTP_HOST,
|
||||||
|
SMTP_PORT: process.env.SMTP_PORT,
|
||||||
|
SMTP_SECURE: process.env.SMTP_SECURE,
|
||||||
|
SMTP_USER: process.env.SMTP_USER,
|
||||||
|
SMTP_PASS: process.env.SMTP_PASS,
|
||||||
|
SMTP_FROM_EMAIL: process.env.SMTP_FROM_EMAIL,
|
||||||
|
},
|
||||||
|
// Development Environment Settings
|
||||||
|
env_development: {
|
||||||
|
NODE_ENV: 'development',
|
||||||
|
name: 'flyer-crawler-worker-dev',
|
||||||
|
watch: true,
|
||||||
|
ignore_watch: ['node_modules', 'logs', '*.log', 'flyer-images', '.git'],
|
||||||
|
// Inherit secrets from the deployment environment
|
||||||
|
DB_HOST: process.env.DB_HOST,
|
||||||
|
DB_USER: process.env.DB_USER,
|
||||||
|
DB_PASSWORD: process.env.DB_PASSWORD,
|
||||||
|
DB_NAME: process.env.DB_NAME,
|
||||||
|
REDIS_URL: process.env.REDIS_URL,
|
||||||
|
REDIS_PASSWORD: process.env.REDIS_PASSWORD,
|
||||||
|
FRONTEND_URL: process.env.FRONTEND_URL,
|
||||||
|
JWT_SECRET: process.env.JWT_SECRET,
|
||||||
|
GEMINI_API_KEY: process.env.GEMINI_API_KEY,
|
||||||
|
GOOGLE_MAPS_API_KEY: process.env.GOOGLE_MAPS_API_KEY,
|
||||||
|
SMTP_HOST: process.env.SMTP_HOST,
|
||||||
|
SMTP_PORT: process.env.SMTP_PORT,
|
||||||
|
SMTP_SECURE: process.env.SMTP_SECURE,
|
||||||
|
SMTP_USER: process.env.SMTP_USER,
|
||||||
|
SMTP_PASS: process.env.SMTP_PASS,
|
||||||
|
SMTP_FROM_EMAIL: process.env.SMTP_FROM_EMAIL,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
// --- Analytics Worker ---
|
// --- Analytics Worker ---
|
||||||
name: 'flyer-crawler-analytics-worker',
|
name: 'flyer-crawler-analytics-worker',
|
||||||
script: './node_modules/.bin/tsx',
|
script: './node_modules/.bin/tsx',
|
||||||
args: 'src/services/queueService.server.ts', // tsx will execute this file
|
args: 'src/services/worker.ts', // tsx will execute this file
|
||||||
// Production Environment Settings
|
// Production Environment Settings
|
||||||
env_production: {
|
env_production: {
|
||||||
NODE_ENV: 'production',
|
NODE_ENV: 'production',
|
||||||
name: 'flyer-crawler-analytics-worker',
|
name: 'flyer-crawler-analytics-worker',
|
||||||
cwd: '/var/www/flyer-crawler.projectium.com',
|
cwd: '/var/www/flyer-crawler.projectium.com',
|
||||||
|
// Inherit secrets from the deployment environment
|
||||||
|
DB_HOST: process.env.DB_HOST,
|
||||||
|
DB_USER: process.env.DB_USER,
|
||||||
|
DB_PASSWORD: process.env.DB_PASSWORD,
|
||||||
|
DB_NAME: process.env.DB_NAME,
|
||||||
|
REDIS_URL: process.env.REDIS_URL,
|
||||||
|
REDIS_PASSWORD: process.env.REDIS_PASSWORD,
|
||||||
|
FRONTEND_URL: process.env.FRONTEND_URL,
|
||||||
|
JWT_SECRET: process.env.JWT_SECRET,
|
||||||
|
GEMINI_API_KEY: process.env.GEMINI_API_KEY,
|
||||||
|
GOOGLE_MAPS_API_KEY: process.env.GOOGLE_MAPS_API_KEY,
|
||||||
|
SMTP_HOST: process.env.SMTP_HOST,
|
||||||
|
SMTP_PORT: process.env.SMTP_PORT,
|
||||||
|
SMTP_SECURE: process.env.SMTP_SECURE,
|
||||||
|
SMTP_USER: process.env.SMTP_USER,
|
||||||
|
SMTP_PASS: process.env.SMTP_PASS,
|
||||||
|
SMTP_FROM_EMAIL: process.env.SMTP_FROM_EMAIL,
|
||||||
},
|
},
|
||||||
// Test Environment Settings
|
// Test Environment Settings
|
||||||
env_test: {
|
env_test: {
|
||||||
NODE_ENV: 'development',
|
NODE_ENV: 'test',
|
||||||
name: 'flyer-crawler-analytics-worker-test',
|
name: 'flyer-crawler-analytics-worker-test',
|
||||||
cwd: '/var/www/flyer-crawler-test.projectium.com',
|
cwd: '/var/www/flyer-crawler-test.projectium.com',
|
||||||
|
// Inherit secrets from the deployment environment
|
||||||
|
DB_HOST: process.env.DB_HOST,
|
||||||
|
DB_USER: process.env.DB_USER,
|
||||||
|
DB_PASSWORD: process.env.DB_PASSWORD,
|
||||||
|
DB_NAME: process.env.DB_NAME,
|
||||||
|
REDIS_URL: process.env.REDIS_URL,
|
||||||
|
REDIS_PASSWORD: process.env.REDIS_PASSWORD,
|
||||||
|
FRONTEND_URL: process.env.FRONTEND_URL,
|
||||||
|
JWT_SECRET: process.env.JWT_SECRET,
|
||||||
|
GEMINI_API_KEY: process.env.GEMINI_API_KEY,
|
||||||
|
GOOGLE_MAPS_API_KEY: process.env.GOOGLE_MAPS_API_KEY,
|
||||||
|
SMTP_HOST: process.env.SMTP_HOST,
|
||||||
|
SMTP_PORT: process.env.SMTP_PORT,
|
||||||
|
SMTP_SECURE: process.env.SMTP_SECURE,
|
||||||
|
SMTP_USER: process.env.SMTP_USER,
|
||||||
|
SMTP_PASS: process.env.SMTP_PASS,
|
||||||
|
SMTP_FROM_EMAIL: process.env.SMTP_FROM_EMAIL,
|
||||||
|
},
|
||||||
|
// Development Environment Settings
|
||||||
|
env_development: {
|
||||||
|
NODE_ENV: 'development',
|
||||||
|
name: 'flyer-crawler-analytics-worker-dev',
|
||||||
|
watch: true,
|
||||||
|
ignore_watch: ['node_modules', 'logs', '*.log', 'flyer-images', '.git'],
|
||||||
|
// Inherit secrets from the deployment environment
|
||||||
|
DB_HOST: process.env.DB_HOST,
|
||||||
|
DB_USER: process.env.DB_USER,
|
||||||
|
DB_PASSWORD: process.env.DB_PASSWORD,
|
||||||
|
DB_NAME: process.env.DB_NAME,
|
||||||
|
REDIS_URL: process.env.REDIS_URL,
|
||||||
|
REDIS_PASSWORD: process.env.REDIS_PASSWORD,
|
||||||
|
FRONTEND_URL: process.env.FRONTEND_URL,
|
||||||
|
JWT_SECRET: process.env.JWT_SECRET,
|
||||||
|
GEMINI_API_KEY: process.env.GEMINI_API_KEY,
|
||||||
|
GOOGLE_MAPS_API_KEY: process.env.GOOGLE_MAPS_API_KEY,
|
||||||
|
SMTP_HOST: process.env.SMTP_HOST,
|
||||||
|
SMTP_PORT: process.env.SMTP_PORT,
|
||||||
|
SMTP_SECURE: process.env.SMTP_SECURE,
|
||||||
|
SMTP_USER: process.env.SMTP_USER,
|
||||||
|
SMTP_PASS: process.env.SMTP_PASS,
|
||||||
|
SMTP_FROM_EMAIL: process.env.SMTP_FROM_EMAIL,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -1,21 +1,21 @@
|
|||||||
import globals from "globals";
|
import globals from 'globals';
|
||||||
import tseslint from "typescript-eslint";
|
import tseslint from 'typescript-eslint';
|
||||||
import pluginReact from "eslint-plugin-react";
|
import pluginReact from 'eslint-plugin-react';
|
||||||
import pluginReactHooks from "eslint-plugin-react-hooks";
|
import pluginReactHooks from 'eslint-plugin-react-hooks';
|
||||||
import pluginReactRefresh from "eslint-plugin-react-refresh";
|
import pluginReactRefresh from 'eslint-plugin-react-refresh';
|
||||||
|
|
||||||
export default tseslint.config(
|
export default tseslint.config(
|
||||||
{
|
{
|
||||||
// Global ignores
|
// Global ignores
|
||||||
ignores: ["dist", ".gitea", "node_modules", "*.cjs"],
|
ignores: ['dist', '.gitea', 'node_modules', '*.cjs'],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
// All files
|
// All files
|
||||||
files: ["**/*.{js,mjs,cjs,ts,jsx,tsx}"],
|
files: ['**/*.{js,mjs,cjs,ts,jsx,tsx}'],
|
||||||
plugins: {
|
plugins: {
|
||||||
react: pluginReact,
|
react: pluginReact,
|
||||||
"react-hooks": pluginReactHooks,
|
'react-hooks': pluginReactHooks,
|
||||||
"react-refresh": pluginReactRefresh,
|
'react-refresh': pluginReactRefresh,
|
||||||
},
|
},
|
||||||
languageOptions: {
|
languageOptions: {
|
||||||
globals: {
|
globals: {
|
||||||
@@ -24,10 +24,7 @@ export default tseslint.config(
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
rules: {
|
rules: {
|
||||||
"react-refresh/only-export-components": [
|
'react-refresh/only-export-components': ['warn', { allowConstantExport: true }],
|
||||||
"warn",
|
|
||||||
{ allowConstantExport: true },
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
// TypeScript files
|
// TypeScript files
|
||||||
|
|||||||
2
express.d.ts
vendored
2
express.d.ts
vendored
@@ -1,4 +1,4 @@
|
|||||||
// src/types/express.d.ts
|
// express.d.ts
|
||||||
import { Logger } from 'pino';
|
import { Logger } from 'pino';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
14
index.html
14
index.html
@@ -1,8 +1,8 @@
|
|||||||
<!DOCTYPE html>
|
<!doctype html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Grocery Flyer AI Analyzer</title>
|
<title>Grocery Flyer AI Analyzer</title>
|
||||||
<style>
|
<style>
|
||||||
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap');
|
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap');
|
||||||
@@ -11,10 +11,10 @@
|
|||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
<!-- The stylesheet will be injected here by Vite during the build process -->
|
<!-- The stylesheet will be injected here by Vite during the build process -->
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
<!-- Vite will inject the correct <script> tag here during the build process -->
|
<!-- Vite will inject the correct <script> tag here during the build process -->
|
||||||
<script type="module" src="/src/index.tsx"></script>
|
<script type="module" src="/src/index.tsx"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
@@ -1,8 +1,5 @@
|
|||||||
{
|
{
|
||||||
"name": "Flyer Crawler",
|
"name": "Flyer Crawler",
|
||||||
"description": "Upload a grocery store flyer image to extract item details, prices, and quantities using AI. Get insights, meal plans, and compare prices to save money on your shopping.",
|
"description": "Upload a grocery store flyer image to extract item details, prices, and quantities using AI. Get insights, meal plans, and compare prices to save money on your shopping.",
|
||||||
"requestFramePermissions": [
|
"requestFramePermissions": ["geolocation", "microphone"]
|
||||||
"geolocation",
|
|
||||||
"microphone"
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
4283
package-lock.json
generated
4283
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
14
package.json
14
package.json
@@ -1,17 +1,19 @@
|
|||||||
{
|
{
|
||||||
"name": "flyer-crawler",
|
"name": "flyer-crawler",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.0.2",
|
"version": "0.2.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "concurrently \"npm:start:dev\" \"vite\"",
|
"dev": "concurrently \"npm:start:dev\" \"vite\"",
|
||||||
|
"dev:container": "concurrently \"npm:start:dev\" \"vite --host\"",
|
||||||
"start": "npm run start:prod",
|
"start": "npm run start:prod",
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"test": "NODE_ENV=test tsx ./node_modules/vitest/vitest.mjs run",
|
"test": "cross-env NODE_ENV=test tsx ./node_modules/vitest/vitest.mjs run",
|
||||||
|
"test-wsl": "cross-env NODE_ENV=test vitest run",
|
||||||
"test:coverage": "npm run clean && npm run test:unit -- --coverage && npm run test:integration -- --coverage",
|
"test:coverage": "npm run clean && npm run test:unit -- --coverage && npm run test:integration -- --coverage",
|
||||||
"test:unit": "NODE_ENV=test tsx ./node_modules/vitest/vitest.mjs run --project unit -c vite.config.ts",
|
"test:unit": "NODE_ENV=test tsx --max-old-space-size=8192 ./node_modules/vitest/vitest.mjs run --project unit -c vite.config.ts",
|
||||||
"test:integration": "NODE_ENV=test tsx ./node_modules/vitest/vitest.mjs run --project integration -c vitest.config.integration.ts",
|
"test:integration": "NODE_ENV=test tsx --max-old-space-size=8192 ./node_modules/vitest/vitest.mjs run --project integration -c vitest.config.integration.ts",
|
||||||
"format": "prettier --write .",
|
"format": "prettier --write .",
|
||||||
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
|
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
|
||||||
"type-check": "tsc --noEmit",
|
"type-check": "tsc --noEmit",
|
||||||
@@ -20,6 +22,7 @@
|
|||||||
"start:dev": "NODE_ENV=development tsx watch server.ts",
|
"start:dev": "NODE_ENV=development tsx watch server.ts",
|
||||||
"start:prod": "NODE_ENV=production tsx server.ts",
|
"start:prod": "NODE_ENV=production tsx server.ts",
|
||||||
"start:test": "NODE_ENV=test NODE_V8_COVERAGE=.coverage/tmp/integration-server tsx server.ts",
|
"start:test": "NODE_ENV=test NODE_V8_COVERAGE=.coverage/tmp/integration-server tsx server.ts",
|
||||||
|
"db:reset:dev": "NODE_ENV=development tsx src/db/seed.ts",
|
||||||
"db:reset:test": "NODE_ENV=test tsx src/db/seed.ts",
|
"db:reset:test": "NODE_ENV=test tsx src/db/seed.ts",
|
||||||
"worker:prod": "NODE_ENV=production tsx src/services/queueService.server.ts"
|
"worker:prod": "NODE_ENV=production tsx src/services/queueService.server.ts"
|
||||||
},
|
},
|
||||||
@@ -58,7 +61,7 @@
|
|||||||
"recharts": "^3.4.1",
|
"recharts": "^3.4.1",
|
||||||
"sharp": "^0.34.5",
|
"sharp": "^0.34.5",
|
||||||
"tsx": "^4.20.6",
|
"tsx": "^4.20.6",
|
||||||
"zod": "^4.1.13",
|
"zod": "^4.2.1",
|
||||||
"zxcvbn": "^4.4.2"
|
"zxcvbn": "^4.4.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -95,6 +98,7 @@
|
|||||||
"autoprefixer": "^10.4.22",
|
"autoprefixer": "^10.4.22",
|
||||||
"c8": "^10.1.3",
|
"c8": "^10.1.3",
|
||||||
"concurrently": "^9.2.1",
|
"concurrently": "^9.2.1",
|
||||||
|
"cross-env": "^10.1.0",
|
||||||
"eslint": "9.39.1",
|
"eslint": "9.39.1",
|
||||||
"eslint-config-prettier": "^9.1.0",
|
"eslint-config-prettier": "^9.1.0",
|
||||||
"eslint-plugin-react": "7.37.5",
|
"eslint-plugin-react": "7.37.5",
|
||||||
|
|||||||
@@ -10,7 +10,10 @@ const tailwindConfigPath = path.resolve(process.cwd(), 'tailwind.config.js');
|
|||||||
console.log(`[POSTCSS] Attempting to use Tailwind config at: ${tailwindConfigPath}`);
|
console.log(`[POSTCSS] Attempting to use Tailwind config at: ${tailwindConfigPath}`);
|
||||||
|
|
||||||
// Log to prove the imported config object is what we expect
|
// Log to prove the imported config object is what we expect
|
||||||
console.log('[POSTCSS] Imported tailwind.config.js object:', JSON.stringify(tailwindConfig, null, 2));
|
console.log(
|
||||||
|
'[POSTCSS] Imported tailwind.config.js object:',
|
||||||
|
JSON.stringify(tailwindConfig, null, 2),
|
||||||
|
);
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
plugins: {
|
plugins: {
|
||||||
|
|||||||
@@ -1030,11 +1030,61 @@ DROP FUNCTION IF EXISTS public.fork_recipe(UUID, BIGINT);
|
|||||||
|
|
||||||
CREATE OR REPLACE FUNCTION public.fork_recipe(p_user_id UUID, p_original_recipe_id BIGINT)
|
CREATE OR REPLACE FUNCTION public.fork_recipe(p_user_id UUID, p_original_recipe_id BIGINT)
|
||||||
RETURNS SETOF public.recipes
|
RETURNS SETOF public.recipes
|
||||||
LANGUAGE sql
|
LANGUAGE plpgsql
|
||||||
SECURITY INVOKER
|
SECURITY INVOKER
|
||||||
AS $$
|
AS $$
|
||||||
-- The entire forking logic is now encapsulated in a single, atomic database function.
|
DECLARE
|
||||||
SELECT * FROM public.fork_recipe(p_user_id, p_original_recipe_id);
|
new_recipe_id BIGINT;
|
||||||
|
BEGIN
|
||||||
|
-- 1. Create a copy of the recipe, linking it to the new user and the original recipe.
|
||||||
|
INSERT INTO public.recipes (
|
||||||
|
user_id,
|
||||||
|
original_recipe_id,
|
||||||
|
name,
|
||||||
|
description,
|
||||||
|
instructions,
|
||||||
|
prep_time_minutes,
|
||||||
|
cook_time_minutes,
|
||||||
|
servings,
|
||||||
|
photo_url,
|
||||||
|
calories_per_serving,
|
||||||
|
protein_grams,
|
||||||
|
fat_grams,
|
||||||
|
carb_grams,
|
||||||
|
status -- Forked recipes should be private by default
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
p_user_id,
|
||||||
|
p_original_recipe_id,
|
||||||
|
original.name || ' (Fork)', -- Append '(Fork)' to distinguish it
|
||||||
|
original.description,
|
||||||
|
original.instructions,
|
||||||
|
original.prep_time_minutes,
|
||||||
|
original.cook_time_minutes,
|
||||||
|
original.servings,
|
||||||
|
original.photo_url,
|
||||||
|
original.calories_per_serving,
|
||||||
|
original.protein_grams,
|
||||||
|
original.fat_grams,
|
||||||
|
original.carb_grams,
|
||||||
|
'private'
|
||||||
|
FROM public.recipes AS original
|
||||||
|
WHERE original.recipe_id = p_original_recipe_id
|
||||||
|
RETURNING recipe_id INTO new_recipe_id;
|
||||||
|
|
||||||
|
-- If the original recipe didn't exist, new_recipe_id will be null.
|
||||||
|
IF new_recipe_id IS NULL THEN
|
||||||
|
RETURN;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- 2. Copy all ingredients, tags, and appliances from the original recipe to the new one.
|
||||||
|
INSERT INTO public.recipe_ingredients (recipe_id, master_item_id, quantity, unit) SELECT new_recipe_id, master_item_id, quantity, unit FROM public.recipe_ingredients WHERE recipe_id = p_original_recipe_id;
|
||||||
|
INSERT INTO public.recipe_tags (recipe_id, tag_id) SELECT new_recipe_id, tag_id FROM public.recipe_tags WHERE recipe_id = p_original_recipe_id;
|
||||||
|
INSERT INTO public.recipe_appliances (recipe_id, appliance_id) SELECT new_recipe_id, appliance_id FROM public.recipe_appliances WHERE recipe_id = p_original_recipe_id;
|
||||||
|
|
||||||
|
-- 3. Return the newly created recipe record.
|
||||||
|
RETURN QUERY SELECT * FROM public.recipes WHERE recipe_id = new_recipe_id;
|
||||||
|
END;
|
||||||
$$;
|
$$;
|
||||||
|
|
||||||
|
|
||||||
@@ -1566,4 +1616,3 @@ BEGIN
|
|||||||
bp.price_rank = 1;
|
bp.price_rank = 1;
|
||||||
END;
|
END;
|
||||||
$$ LANGUAGE plpgsql;
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,23 @@
|
|||||||
-- It is idempotent, meaning it can be run multiple times without causing errors.
|
-- It is idempotent, meaning it can be run multiple times without causing errors.
|
||||||
|
|
||||||
-- 1. Pre-populate the master grocery items dictionary.
|
-- 1. Pre-populate the master grocery items dictionary.
|
||||||
-- This block links generic items to their respective categories.
|
-- This MUST run after populating categories.
|
||||||
|
-- Renumbered to 2.
|
||||||
|
|
||||||
|
-- 2. Pre-populate the categories table from a predefined list.
|
||||||
|
-- Renumbered to 1. This MUST run before populating master_grocery_items.
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
INSERT INTO public.categories (name) VALUES
|
||||||
|
('Fruits & Vegetables'), ('Meat & Seafood'), ('Dairy & Eggs'), ('Bakery & Bread'),
|
||||||
|
('Pantry & Dry Goods'), ('Beverages'), ('Frozen Foods'), ('Snacks'), ('Household & Cleaning'),
|
||||||
|
('Personal Care & Health'), ('Baby & Child'), ('Pet Supplies'), ('Deli & Prepared Foods'),
|
||||||
|
('Canned Goods'), ('Condiments & Spices'), ('Breakfast & Cereal'), ('Organic'),
|
||||||
|
('International Foods'), ('Other/Miscellaneous')
|
||||||
|
ON CONFLICT (name) DO NOTHING;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
-- 2. Pre-populate the master grocery items dictionary.
|
||||||
DO $$
|
DO $$
|
||||||
DECLARE
|
DECLARE
|
||||||
fv_cat_id BIGINT; ms_cat_id BIGINT; de_cat_id BIGINT; bb_cat_id BIGINT; pdg_cat_id BIGINT;
|
fv_cat_id BIGINT; ms_cat_id BIGINT; de_cat_id BIGINT; bb_cat_id BIGINT; pdg_cat_id BIGINT;
|
||||||
@@ -53,18 +69,6 @@ BEGIN
|
|||||||
ON CONFLICT (name) DO NOTHING;
|
ON CONFLICT (name) DO NOTHING;
|
||||||
END $$;
|
END $$;
|
||||||
|
|
||||||
-- 2. Pre-populate the categories table from a predefined list.
|
|
||||||
DO $$
|
|
||||||
BEGIN
|
|
||||||
INSERT INTO public.categories (name) VALUES
|
|
||||||
('Fruits & Vegetables'), ('Meat & Seafood'), ('Dairy & Eggs'), ('Bakery & Bread'),
|
|
||||||
('Pantry & Dry Goods'), ('Beverages'), ('Frozen Foods'), ('Snacks'), ('Household & Cleaning'),
|
|
||||||
('Personal Care & Health'), ('Baby & Child'), ('Pet Supplies'), ('Deli & Prepared Foods'),
|
|
||||||
('Canned Goods'), ('Condiments & Spices'), ('Breakfast & Cereal'), ('Organic'),
|
|
||||||
('International Foods'), ('Other/Miscellaneous')
|
|
||||||
ON CONFLICT (name) DO NOTHING;
|
|
||||||
END $$;
|
|
||||||
|
|
||||||
-- 3. Pre-populate the brands and products tables.
|
-- 3. Pre-populate the brands and products tables.
|
||||||
-- This block adds common brands and links them to specific products.
|
-- This block adds common brands and links them to specific products.
|
||||||
DO $$
|
DO $$
|
||||||
|
|||||||
@@ -92,6 +92,7 @@ CREATE TABLE IF NOT EXISTS public.stores (
|
|||||||
created_at TIMESTAMPTZ DEFAULT now() NOT NULL,
|
created_at TIMESTAMPTZ DEFAULT now() NOT NULL,
|
||||||
updated_at TIMESTAMPTZ DEFAULT now() NOT NULL,
|
updated_at TIMESTAMPTZ DEFAULT now() NOT NULL,
|
||||||
created_by UUID REFERENCES public.users(user_id) ON DELETE SET NULL
|
created_by UUID REFERENCES public.users(user_id) ON DELETE SET NULL
|
||||||
|
);
|
||||||
COMMENT ON TABLE public.stores IS 'Stores metadata for grocery store chains (e.g., Safeway, Kroger).';
|
COMMENT ON TABLE public.stores IS 'Stores metadata for grocery store chains (e.g., Safeway, Kroger).';
|
||||||
|
|
||||||
-- 5. The 'categories' table for normalized category data.
|
-- 5. The 'categories' table for normalized category data.
|
||||||
@@ -110,7 +111,7 @@ CREATE TABLE IF NOT EXISTS public.flyers (
|
|||||||
image_url TEXT NOT NULL,
|
image_url TEXT NOT NULL,
|
||||||
icon_url TEXT,
|
icon_url TEXT,
|
||||||
checksum TEXT UNIQUE,
|
checksum TEXT UNIQUE,
|
||||||
store_id BIGINT REFERENCES public.stores(store_id),
|
store_id BIGINT REFERENCES public.stores(store_id) ON DELETE CASCADE,
|
||||||
valid_from DATE,
|
valid_from DATE,
|
||||||
valid_to DATE,
|
valid_to DATE,
|
||||||
store_address TEXT,
|
store_address TEXT,
|
||||||
@@ -138,7 +139,7 @@ CREATE INDEX IF NOT EXISTS idx_flyers_valid_to_file_name ON public.flyers (valid
|
|||||||
CREATE TABLE IF NOT EXISTS public.master_grocery_items (
|
CREATE TABLE IF NOT EXISTS public.master_grocery_items (
|
||||||
master_grocery_item_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
|
master_grocery_item_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
|
||||||
name TEXT NOT NULL UNIQUE,
|
name TEXT NOT NULL UNIQUE,
|
||||||
category_id BIGINT REFERENCES public.categories(category_id),
|
category_id BIGINT REFERENCES public.categories(category_id) ON DELETE SET NULL,
|
||||||
is_allergen BOOLEAN DEFAULT false,
|
is_allergen BOOLEAN DEFAULT false,
|
||||||
allergy_info JSONB,
|
allergy_info JSONB,
|
||||||
created_at TIMESTAMPTZ DEFAULT now() NOT NULL,
|
created_at TIMESTAMPTZ DEFAULT now() NOT NULL,
|
||||||
@@ -161,6 +162,38 @@ CREATE TABLE IF NOT EXISTS public.user_watched_items (
|
|||||||
COMMENT ON TABLE public.user_watched_items IS 'A linking table that represents a user''s personal watchlist of grocery items.';
|
COMMENT ON TABLE public.user_watched_items IS 'A linking table that represents a user''s personal watchlist of grocery items.';
|
||||||
CREATE INDEX IF NOT EXISTS idx_user_watched_items_master_item_id ON public.user_watched_items(master_item_id);
|
CREATE INDEX IF NOT EXISTS idx_user_watched_items_master_item_id ON public.user_watched_items(master_item_id);
|
||||||
|
|
||||||
|
-- 23. Store brand information. (Moved up due to dependency in flyer_items)
|
||||||
|
CREATE TABLE IF NOT EXISTS public.brands (
|
||||||
|
brand_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
|
||||||
|
name TEXT NOT NULL UNIQUE,
|
||||||
|
logo_url TEXT,
|
||||||
|
store_id BIGINT REFERENCES public.stores(store_id) ON DELETE SET NULL,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT now() NOT NULL,
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
COMMENT ON TABLE public.brands IS 'Stores brand names like "Coca-Cola", "Maple Leaf", or "Kraft".';
|
||||||
|
COMMENT ON COLUMN public.brands.store_id IS 'If this is a store-specific brand (e.g., President''s Choice), this links to the parent store.';
|
||||||
|
|
||||||
|
-- 24. For specific products, linking a master item with a brand and size. (Moved up due to dependency in flyer_items)
|
||||||
|
CREATE TABLE IF NOT EXISTS public.products (
|
||||||
|
product_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
|
||||||
|
master_item_id BIGINT NOT NULL REFERENCES public.master_grocery_items(master_grocery_item_id) ON DELETE CASCADE,
|
||||||
|
brand_id BIGINT REFERENCES public.brands(brand_id) ON DELETE SET NULL,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
size TEXT,
|
||||||
|
upc_code TEXT UNIQUE,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT now() NOT NULL,
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
COMMENT ON TABLE public.products IS 'Represents a specific, sellable product, combining a generic item with a brand and size.';
|
||||||
|
COMMENT ON COLUMN public.products.upc_code IS 'Universal Product Code, if available, for exact product matching.';
|
||||||
|
COMMENT ON COLUMN public.products.brand_id IS 'Can be null for generic/store-brand items.';
|
||||||
|
COMMENT ON COLUMN public.products.name IS 'Prime Raised without Antibiotics Chicken Breast.';
|
||||||
|
COMMENT ON COLUMN public.products.size IS 'e.g., "4L", "500g".';
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_products_master_item_id ON public.products(master_item_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_products_brand_id ON public.products(brand_id);
|
||||||
|
|
||||||
-- 9. The 'flyer_items' table. This stores individual items from flyers.
|
-- 9. The 'flyer_items' table. This stores individual items from flyers.
|
||||||
CREATE TABLE IF NOT EXISTS public.flyer_items (
|
CREATE TABLE IF NOT EXISTS public.flyer_items (
|
||||||
flyer_item_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
|
flyer_item_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
|
||||||
@@ -170,13 +203,13 @@ CREATE TABLE IF NOT EXISTS public.flyer_items (
|
|||||||
price_in_cents INTEGER,
|
price_in_cents INTEGER,
|
||||||
quantity_num NUMERIC,
|
quantity_num NUMERIC,
|
||||||
quantity TEXT NOT NULL,
|
quantity TEXT NOT NULL,
|
||||||
category_id BIGINT REFERENCES public.categories(category_id),
|
category_id BIGINT REFERENCES public.categories(category_id) ON DELETE SET NULL,
|
||||||
category_name TEXT,
|
category_name TEXT,
|
||||||
unit_price JSONB,
|
unit_price JSONB,
|
||||||
view_count INTEGER DEFAULT 0 NOT NULL,
|
view_count INTEGER DEFAULT 0 NOT NULL,
|
||||||
click_count INTEGER DEFAULT 0 NOT NULL,
|
click_count INTEGER DEFAULT 0 NOT NULL,
|
||||||
master_item_id BIGINT REFERENCES public.master_grocery_items(master_grocery_item_id),
|
master_item_id BIGINT REFERENCES public.master_grocery_items(master_grocery_item_id) ON DELETE SET NULL,
|
||||||
product_id BIGINT,
|
product_id BIGINT REFERENCES public.products(product_id) ON DELETE SET NULL,
|
||||||
created_at TIMESTAMPTZ DEFAULT now() NOT NULL,
|
created_at TIMESTAMPTZ DEFAULT now() NOT NULL,
|
||||||
updated_at TIMESTAMPTZ DEFAULT now() NOT NULL
|
updated_at TIMESTAMPTZ DEFAULT now() NOT NULL
|
||||||
);
|
);
|
||||||
@@ -293,7 +326,7 @@ CREATE INDEX IF NOT EXISTS idx_shopping_lists_user_id ON public.shopping_lists(u
|
|||||||
CREATE TABLE IF NOT EXISTS public.shopping_list_items (
|
CREATE TABLE IF NOT EXISTS public.shopping_list_items (
|
||||||
shopping_list_item_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
|
shopping_list_item_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
|
||||||
shopping_list_id BIGINT NOT NULL REFERENCES public.shopping_lists(shopping_list_id) ON DELETE CASCADE,
|
shopping_list_id BIGINT NOT NULL REFERENCES public.shopping_lists(shopping_list_id) ON DELETE CASCADE,
|
||||||
master_item_id BIGINT REFERENCES public.master_grocery_items(master_grocery_item_id),
|
master_item_id BIGINT REFERENCES public.master_grocery_items(master_grocery_item_id) ON DELETE CASCADE,
|
||||||
custom_item_name TEXT,
|
custom_item_name TEXT,
|
||||||
quantity NUMERIC DEFAULT 1 NOT NULL,
|
quantity NUMERIC DEFAULT 1 NOT NULL,
|
||||||
is_purchased BOOLEAN DEFAULT false NOT NULL,
|
is_purchased BOOLEAN DEFAULT false NOT NULL,
|
||||||
@@ -358,7 +391,7 @@ CREATE INDEX IF NOT EXISTS idx_shared_menu_plans_shared_with_user_id ON public.s
|
|||||||
CREATE TABLE IF NOT EXISTS public.suggested_corrections (
|
CREATE TABLE IF NOT EXISTS public.suggested_corrections (
|
||||||
suggested_correction_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
|
suggested_correction_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
|
||||||
flyer_item_id BIGINT NOT NULL REFERENCES public.flyer_items(flyer_item_id) ON DELETE CASCADE,
|
flyer_item_id BIGINT NOT NULL REFERENCES public.flyer_items(flyer_item_id) ON DELETE CASCADE,
|
||||||
user_id UUID NOT NULL REFERENCES public.users(user_id),
|
user_id UUID NOT NULL REFERENCES public.users(user_id) ON DELETE CASCADE,
|
||||||
correction_type TEXT NOT NULL,
|
correction_type TEXT NOT NULL,
|
||||||
suggested_value TEXT NOT NULL,
|
suggested_value TEXT NOT NULL,
|
||||||
status TEXT DEFAULT 'pending' NOT NULL,
|
status TEXT DEFAULT 'pending' NOT NULL,
|
||||||
@@ -378,9 +411,9 @@ CREATE INDEX IF NOT EXISTS idx_suggested_corrections_pending ON public.suggested
|
|||||||
-- 21. For prices submitted directly by users from in-store.
|
-- 21. For prices submitted directly by users from in-store.
|
||||||
CREATE TABLE IF NOT EXISTS public.user_submitted_prices (
|
CREATE TABLE IF NOT EXISTS public.user_submitted_prices (
|
||||||
user_submitted_price_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
|
user_submitted_price_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
|
||||||
user_id UUID NOT NULL REFERENCES public.users(user_id),
|
user_id UUID NOT NULL REFERENCES public.users(user_id) ON DELETE CASCADE,
|
||||||
master_item_id BIGINT NOT NULL REFERENCES public.master_grocery_items(master_grocery_item_id),
|
master_item_id BIGINT NOT NULL REFERENCES public.master_grocery_items(master_grocery_item_id) ON DELETE CASCADE,
|
||||||
store_id BIGINT NOT NULL REFERENCES public.stores(store_id),
|
store_id BIGINT NOT NULL REFERENCES public.stores(store_id) ON DELETE CASCADE,
|
||||||
price_in_cents INTEGER NOT NULL,
|
price_in_cents INTEGER NOT NULL,
|
||||||
photo_url TEXT,
|
photo_url TEXT,
|
||||||
upvotes INTEGER DEFAULT 0 NOT NULL,
|
upvotes INTEGER DEFAULT 0 NOT NULL,
|
||||||
@@ -408,38 +441,6 @@ COMMENT ON TABLE public.unmatched_flyer_items IS 'A queue for reviewing flyer it
|
|||||||
CREATE INDEX IF NOT EXISTS idx_unmatched_flyer_items_flyer_item_id ON public.unmatched_flyer_items(flyer_item_id);
|
CREATE INDEX IF NOT EXISTS idx_unmatched_flyer_items_flyer_item_id ON public.unmatched_flyer_items(flyer_item_id);
|
||||||
CREATE INDEX IF NOT EXISTS idx_unmatched_flyer_items_pending ON public.unmatched_flyer_items (created_at) WHERE status = 'pending';
|
CREATE INDEX IF NOT EXISTS idx_unmatched_flyer_items_pending ON public.unmatched_flyer_items (created_at) WHERE status = 'pending';
|
||||||
|
|
||||||
-- 23. Store brand information.
|
|
||||||
CREATE TABLE IF NOT EXISTS public.brands (
|
|
||||||
brand_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
|
|
||||||
name TEXT NOT NULL UNIQUE,
|
|
||||||
logo_url TEXT,
|
|
||||||
store_id BIGINT REFERENCES public.stores(store_id) ON DELETE SET NULL,
|
|
||||||
created_at TIMESTAMPTZ DEFAULT now() NOT NULL,
|
|
||||||
updated_at TIMESTAMPTZ DEFAULT now() NOT NULL
|
|
||||||
);
|
|
||||||
COMMENT ON TABLE public.brands IS 'Stores brand names like "Coca-Cola", "Maple Leaf", or "Kraft".';
|
|
||||||
COMMENT ON COLUMN public.brands.store_id IS 'If this is a store-specific brand (e.g., President''s Choice), this links to the parent store.';
|
|
||||||
|
|
||||||
-- 24. For specific products, linking a master item with a brand and size.
|
|
||||||
CREATE TABLE IF NOT EXISTS public.products (
|
|
||||||
product_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
|
|
||||||
master_item_id BIGINT NOT NULL REFERENCES public.master_grocery_items(master_grocery_item_id),
|
|
||||||
brand_id BIGINT REFERENCES public.brands(brand_id),
|
|
||||||
name TEXT NOT NULL,
|
|
||||||
description TEXT,
|
|
||||||
size TEXT,
|
|
||||||
upc_code TEXT UNIQUE,
|
|
||||||
created_at TIMESTAMPTZ DEFAULT now() NOT NULL,
|
|
||||||
updated_at TIMESTAMPTZ DEFAULT now() NOT NULL
|
|
||||||
);
|
|
||||||
COMMENT ON TABLE public.products IS 'Represents a specific, sellable product, combining a generic item with a brand and size.';
|
|
||||||
COMMENT ON COLUMN public.products.upc_code IS 'Universal Product Code, if available, for exact product matching.';
|
|
||||||
COMMENT ON COLUMN public.products.brand_id IS 'Can be null for generic/store-brand items.';
|
|
||||||
COMMENT ON COLUMN public.products.name IS 'Prime Raised without Antibiotics Chicken Breast.';
|
|
||||||
COMMENT ON COLUMN public.products.size IS 'e.g., "4L", "500g".';
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_products_master_item_id ON public.products(master_item_id);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_products_brand_id ON public.products(brand_id);
|
|
||||||
|
|
||||||
-- 25. Linking table for when one flyer is valid for multiple locations.
|
-- 25. Linking table for when one flyer is valid for multiple locations.
|
||||||
CREATE TABLE IF NOT EXISTS public.flyer_locations (
|
CREATE TABLE IF NOT EXISTS public.flyer_locations (
|
||||||
flyer_id BIGINT NOT NULL REFERENCES public.flyers(flyer_id) ON DELETE CASCADE,
|
flyer_id BIGINT NOT NULL REFERENCES public.flyers(flyer_id) ON DELETE CASCADE,
|
||||||
@@ -495,7 +496,7 @@ CREATE UNIQUE INDEX IF NOT EXISTS idx_recipes_unique_system_recipe_name ON publi
|
|||||||
CREATE TABLE IF NOT EXISTS public.recipe_ingredients (
|
CREATE TABLE IF NOT EXISTS public.recipe_ingredients (
|
||||||
recipe_ingredient_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
|
recipe_ingredient_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
|
||||||
recipe_id BIGINT NOT NULL REFERENCES public.recipes(recipe_id) ON DELETE CASCADE,
|
recipe_id BIGINT NOT NULL REFERENCES public.recipes(recipe_id) ON DELETE CASCADE,
|
||||||
master_item_id BIGINT NOT NULL REFERENCES public.master_grocery_items(master_grocery_item_id),
|
master_item_id BIGINT NOT NULL REFERENCES public.master_grocery_items(master_grocery_item_id) ON DELETE CASCADE,
|
||||||
quantity NUMERIC NOT NULL,
|
quantity NUMERIC NOT NULL,
|
||||||
unit TEXT NOT NULL,
|
unit TEXT NOT NULL,
|
||||||
created_at TIMESTAMPTZ DEFAULT now() NOT NULL,
|
created_at TIMESTAMPTZ DEFAULT now() NOT NULL,
|
||||||
@@ -779,7 +780,7 @@ CREATE INDEX IF NOT EXISTS idx_shopping_trips_shopping_list_id ON public.shoppin
|
|||||||
CREATE TABLE IF NOT EXISTS public.shopping_trip_items (
|
CREATE TABLE IF NOT EXISTS public.shopping_trip_items (
|
||||||
shopping_trip_item_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
|
shopping_trip_item_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
|
||||||
shopping_trip_id BIGINT NOT NULL REFERENCES public.shopping_trips(shopping_trip_id) ON DELETE CASCADE,
|
shopping_trip_id BIGINT NOT NULL REFERENCES public.shopping_trips(shopping_trip_id) ON DELETE CASCADE,
|
||||||
master_item_id BIGINT REFERENCES public.master_grocery_items(master_grocery_item_id),
|
master_item_id BIGINT REFERENCES public.master_grocery_items(master_grocery_item_id) ON DELETE SET NULL,
|
||||||
custom_item_name TEXT,
|
custom_item_name TEXT,
|
||||||
quantity NUMERIC NOT NULL,
|
quantity NUMERIC NOT NULL,
|
||||||
price_paid_cents INTEGER,
|
price_paid_cents INTEGER,
|
||||||
@@ -843,7 +844,7 @@ CREATE INDEX IF NOT EXISTS idx_user_follows_following_id ON public.user_follows(
|
|||||||
CREATE TABLE IF NOT EXISTS public.receipts (
|
CREATE TABLE IF NOT EXISTS public.receipts (
|
||||||
receipt_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
|
receipt_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
|
||||||
user_id UUID NOT NULL REFERENCES public.users(user_id) ON DELETE CASCADE,
|
user_id UUID NOT NULL REFERENCES public.users(user_id) ON DELETE CASCADE,
|
||||||
store_id BIGINT REFERENCES public.stores(store_id),
|
store_id BIGINT REFERENCES public.stores(store_id) ON DELETE CASCADE,
|
||||||
receipt_image_url TEXT NOT NULL,
|
receipt_image_url TEXT NOT NULL,
|
||||||
transaction_date TIMESTAMPTZ,
|
transaction_date TIMESTAMPTZ,
|
||||||
total_amount_cents INTEGER,
|
total_amount_cents INTEGER,
|
||||||
@@ -864,8 +865,8 @@ CREATE TABLE IF NOT EXISTS public.receipt_items (
|
|||||||
raw_item_description TEXT NOT NULL,
|
raw_item_description TEXT NOT NULL,
|
||||||
quantity NUMERIC DEFAULT 1 NOT NULL,
|
quantity NUMERIC DEFAULT 1 NOT NULL,
|
||||||
price_paid_cents INTEGER NOT NULL,
|
price_paid_cents INTEGER NOT NULL,
|
||||||
master_item_id BIGINT REFERENCES public.master_grocery_items(master_grocery_item_id),
|
master_item_id BIGINT REFERENCES public.master_grocery_items(master_grocery_item_id) ON DELETE SET NULL,
|
||||||
product_id BIGINT REFERENCES public.products(product_id),
|
product_id BIGINT REFERENCES public.products(product_id) ON DELETE SET NULL,
|
||||||
status TEXT DEFAULT 'unmatched' NOT NULL CHECK (status IN ('unmatched', 'matched', 'needs_review', 'ignored')),
|
status TEXT DEFAULT 'unmatched' NOT NULL CHECK (status IN ('unmatched', 'matched', 'needs_review', 'ignored')),
|
||||||
created_at TIMESTAMPTZ DEFAULT now() NOT NULL,
|
created_at TIMESTAMPTZ DEFAULT now() NOT NULL,
|
||||||
updated_at TIMESTAMPTZ DEFAULT now() NOT NULL
|
updated_at TIMESTAMPTZ DEFAULT now() NOT NULL
|
||||||
|
|||||||
@@ -127,7 +127,7 @@ CREATE TABLE IF NOT EXISTS public.flyers (
|
|||||||
image_url TEXT NOT NULL,
|
image_url TEXT NOT NULL,
|
||||||
icon_url TEXT,
|
icon_url TEXT,
|
||||||
checksum TEXT UNIQUE,
|
checksum TEXT UNIQUE,
|
||||||
store_id BIGINT REFERENCES public.stores(store_id),
|
store_id BIGINT REFERENCES public.stores(store_id) ON DELETE CASCADE,
|
||||||
valid_from DATE,
|
valid_from DATE,
|
||||||
valid_to DATE,
|
valid_to DATE,
|
||||||
store_address TEXT,
|
store_address TEXT,
|
||||||
@@ -155,7 +155,7 @@ CREATE INDEX IF NOT EXISTS idx_flyers_valid_to_file_name ON public.flyers (valid
|
|||||||
CREATE TABLE IF NOT EXISTS public.master_grocery_items (
|
CREATE TABLE IF NOT EXISTS public.master_grocery_items (
|
||||||
master_grocery_item_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
|
master_grocery_item_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
|
||||||
name TEXT NOT NULL UNIQUE,
|
name TEXT NOT NULL UNIQUE,
|
||||||
category_id BIGINT REFERENCES public.categories(category_id),
|
category_id BIGINT REFERENCES public.categories(category_id) ON DELETE SET NULL,
|
||||||
is_allergen BOOLEAN DEFAULT false,
|
is_allergen BOOLEAN DEFAULT false,
|
||||||
allergy_info JSONB,
|
allergy_info JSONB,
|
||||||
created_at TIMESTAMPTZ DEFAULT now() NOT NULL,
|
created_at TIMESTAMPTZ DEFAULT now() NOT NULL,
|
||||||
@@ -178,6 +178,38 @@ CREATE TABLE IF NOT EXISTS public.user_watched_items (
|
|||||||
COMMENT ON TABLE public.user_watched_items IS 'A linking table that represents a user''s personal watchlist of grocery items.';
|
COMMENT ON TABLE public.user_watched_items IS 'A linking table that represents a user''s personal watchlist of grocery items.';
|
||||||
CREATE INDEX IF NOT EXISTS idx_user_watched_items_master_item_id ON public.user_watched_items(master_item_id);
|
CREATE INDEX IF NOT EXISTS idx_user_watched_items_master_item_id ON public.user_watched_items(master_item_id);
|
||||||
|
|
||||||
|
-- 23. Store brand information. (Moved up due to dependency in flyer_items)
|
||||||
|
CREATE TABLE IF NOT EXISTS public.brands (
|
||||||
|
brand_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
|
||||||
|
name TEXT NOT NULL UNIQUE,
|
||||||
|
logo_url TEXT,
|
||||||
|
store_id BIGINT REFERENCES public.stores(store_id) ON DELETE SET NULL,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT now() NOT NULL,
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
COMMENT ON TABLE public.brands IS 'Stores brand names like "Coca-Cola", "Maple Leaf", or "Kraft".';
|
||||||
|
COMMENT ON COLUMN public.brands.store_id IS 'If this is a store-specific brand (e.g., President''s Choice), this links to the parent store.';
|
||||||
|
|
||||||
|
-- 24. For specific products, linking a master item with a brand and size. (Moved up due to dependency in flyer_items)
|
||||||
|
CREATE TABLE IF NOT EXISTS public.products (
|
||||||
|
product_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
|
||||||
|
master_item_id BIGINT NOT NULL REFERENCES public.master_grocery_items(master_grocery_item_id) ON DELETE CASCADE,
|
||||||
|
brand_id BIGINT REFERENCES public.brands(brand_id) ON DELETE SET NULL,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
size TEXT,
|
||||||
|
upc_code TEXT UNIQUE,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT now() NOT NULL,
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
COMMENT ON TABLE public.products IS 'Represents a specific, sellable product, combining a generic item with a brand and size.';
|
||||||
|
COMMENT ON COLUMN public.products.upc_code IS 'Universal Product Code, if available, for exact product matching.';
|
||||||
|
COMMENT ON COLUMN public.products.brand_id IS 'Can be null for generic/store-brand items.';
|
||||||
|
COMMENT ON COLUMN public.products.name IS 'Prime Raised without Antibiotics Chicken Breast.';
|
||||||
|
COMMENT ON COLUMN public.products.size IS 'e.g., "4L", "500g".';
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_products_master_item_id ON public.products(master_item_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_products_brand_id ON public.products(brand_id);
|
||||||
|
|
||||||
-- 9. The 'flyer_items' table. This stores individual items from flyers.
|
-- 9. The 'flyer_items' table. This stores individual items from flyers.
|
||||||
CREATE TABLE IF NOT EXISTS public.flyer_items (
|
CREATE TABLE IF NOT EXISTS public.flyer_items (
|
||||||
flyer_item_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
|
flyer_item_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
|
||||||
@@ -187,13 +219,13 @@ CREATE TABLE IF NOT EXISTS public.flyer_items (
|
|||||||
price_in_cents INTEGER,
|
price_in_cents INTEGER,
|
||||||
quantity_num NUMERIC,
|
quantity_num NUMERIC,
|
||||||
quantity TEXT NOT NULL,
|
quantity TEXT NOT NULL,
|
||||||
category_id BIGINT REFERENCES public.categories(category_id),
|
category_id BIGINT REFERENCES public.categories(category_id) ON DELETE SET NULL,
|
||||||
category_name TEXT,
|
category_name TEXT,
|
||||||
unit_price JSONB,
|
unit_price JSONB,
|
||||||
view_count INTEGER DEFAULT 0 NOT NULL,
|
view_count INTEGER DEFAULT 0 NOT NULL,
|
||||||
click_count INTEGER DEFAULT 0 NOT NULL,
|
click_count INTEGER DEFAULT 0 NOT NULL,
|
||||||
master_item_id BIGINT REFERENCES public.master_grocery_items(master_grocery_item_id),
|
master_item_id BIGINT REFERENCES public.master_grocery_items(master_grocery_item_id) ON DELETE SET NULL,
|
||||||
product_id BIGINT,
|
product_id BIGINT REFERENCES public.products(product_id) ON DELETE SET NULL,
|
||||||
created_at TIMESTAMPTZ DEFAULT now() NOT NULL,
|
created_at TIMESTAMPTZ DEFAULT now() NOT NULL,
|
||||||
updated_at TIMESTAMPTZ DEFAULT now() NOT NULL
|
updated_at TIMESTAMPTZ DEFAULT now() NOT NULL
|
||||||
);
|
);
|
||||||
@@ -310,7 +342,7 @@ CREATE INDEX IF NOT EXISTS idx_shopping_lists_user_id ON public.shopping_lists(u
|
|||||||
CREATE TABLE IF NOT EXISTS public.shopping_list_items (
|
CREATE TABLE IF NOT EXISTS public.shopping_list_items (
|
||||||
shopping_list_item_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
|
shopping_list_item_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
|
||||||
shopping_list_id BIGINT NOT NULL REFERENCES public.shopping_lists(shopping_list_id) ON DELETE CASCADE,
|
shopping_list_id BIGINT NOT NULL REFERENCES public.shopping_lists(shopping_list_id) ON DELETE CASCADE,
|
||||||
master_item_id BIGINT REFERENCES public.master_grocery_items(master_grocery_item_id),
|
master_item_id BIGINT REFERENCES public.master_grocery_items(master_grocery_item_id) ON DELETE CASCADE,
|
||||||
custom_item_name TEXT,
|
custom_item_name TEXT,
|
||||||
quantity NUMERIC DEFAULT 1 NOT NULL,
|
quantity NUMERIC DEFAULT 1 NOT NULL,
|
||||||
is_purchased BOOLEAN DEFAULT false NOT NULL,
|
is_purchased BOOLEAN DEFAULT false NOT NULL,
|
||||||
@@ -375,7 +407,7 @@ CREATE INDEX IF NOT EXISTS idx_shared_menu_plans_shared_with_user_id ON public.s
|
|||||||
CREATE TABLE IF NOT EXISTS public.suggested_corrections (
|
CREATE TABLE IF NOT EXISTS public.suggested_corrections (
|
||||||
suggested_correction_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
|
suggested_correction_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
|
||||||
flyer_item_id BIGINT NOT NULL REFERENCES public.flyer_items(flyer_item_id) ON DELETE CASCADE,
|
flyer_item_id BIGINT NOT NULL REFERENCES public.flyer_items(flyer_item_id) ON DELETE CASCADE,
|
||||||
user_id UUID NOT NULL REFERENCES public.users(user_id),
|
user_id UUID NOT NULL REFERENCES public.users(user_id) ON DELETE CASCADE,
|
||||||
correction_type TEXT NOT NULL,
|
correction_type TEXT NOT NULL,
|
||||||
suggested_value TEXT NOT NULL,
|
suggested_value TEXT NOT NULL,
|
||||||
status TEXT DEFAULT 'pending' NOT NULL,
|
status TEXT DEFAULT 'pending' NOT NULL,
|
||||||
@@ -395,9 +427,9 @@ CREATE INDEX IF NOT EXISTS idx_suggested_corrections_pending ON public.suggested
|
|||||||
-- 21. For prices submitted directly by users from in-store.
|
-- 21. For prices submitted directly by users from in-store.
|
||||||
CREATE TABLE IF NOT EXISTS public.user_submitted_prices (
|
CREATE TABLE IF NOT EXISTS public.user_submitted_prices (
|
||||||
user_submitted_price_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
|
user_submitted_price_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
|
||||||
user_id UUID NOT NULL REFERENCES public.users(user_id),
|
user_id UUID NOT NULL REFERENCES public.users(user_id) ON DELETE CASCADE,
|
||||||
master_item_id BIGINT NOT NULL REFERENCES public.master_grocery_items(master_grocery_item_id),
|
master_item_id BIGINT NOT NULL REFERENCES public.master_grocery_items(master_grocery_item_id) ON DELETE CASCADE,
|
||||||
store_id BIGINT NOT NULL REFERENCES public.stores(store_id),
|
store_id BIGINT NOT NULL REFERENCES public.stores(store_id) ON DELETE CASCADE,
|
||||||
price_in_cents INTEGER NOT NULL,
|
price_in_cents INTEGER NOT NULL,
|
||||||
photo_url TEXT,
|
photo_url TEXT,
|
||||||
upvotes INTEGER DEFAULT 0 NOT NULL,
|
upvotes INTEGER DEFAULT 0 NOT NULL,
|
||||||
@@ -424,38 +456,6 @@ COMMENT ON TABLE public.unmatched_flyer_items IS 'A queue for reviewing flyer it
|
|||||||
CREATE INDEX IF NOT EXISTS idx_unmatched_flyer_items_flyer_item_id ON public.unmatched_flyer_items(flyer_item_id);
|
CREATE INDEX IF NOT EXISTS idx_unmatched_flyer_items_flyer_item_id ON public.unmatched_flyer_items(flyer_item_id);
|
||||||
CREATE INDEX IF NOT EXISTS idx_unmatched_flyer_items_pending ON public.unmatched_flyer_items (created_at) WHERE status = 'pending';
|
CREATE INDEX IF NOT EXISTS idx_unmatched_flyer_items_pending ON public.unmatched_flyer_items (created_at) WHERE status = 'pending';
|
||||||
|
|
||||||
-- 23. Store brand information.
|
|
||||||
CREATE TABLE IF NOT EXISTS public.brands (
|
|
||||||
brand_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
|
|
||||||
name TEXT NOT NULL UNIQUE,
|
|
||||||
logo_url TEXT,
|
|
||||||
store_id BIGINT REFERENCES public.stores(store_id) ON DELETE SET NULL,
|
|
||||||
created_at TIMESTAMPTZ DEFAULT now() NOT NULL,
|
|
||||||
updated_at TIMESTAMPTZ DEFAULT now() NOT NULL
|
|
||||||
);
|
|
||||||
COMMENT ON TABLE public.brands IS 'Stores brand names like "Coca-Cola", "Maple Leaf", or "Kraft".';
|
|
||||||
COMMENT ON COLUMN public.brands.store_id IS 'If this is a store-specific brand (e.g., President''s Choice), this links to the parent store.';
|
|
||||||
|
|
||||||
-- 24. For specific products, linking a master item with a brand and size.
|
|
||||||
CREATE TABLE IF NOT EXISTS public.products (
|
|
||||||
product_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
|
|
||||||
master_item_id BIGINT NOT NULL REFERENCES public.master_grocery_items(master_grocery_item_id),
|
|
||||||
brand_id BIGINT REFERENCES public.brands(brand_id),
|
|
||||||
name TEXT NOT NULL,
|
|
||||||
description TEXT,
|
|
||||||
size TEXT,
|
|
||||||
upc_code TEXT UNIQUE,
|
|
||||||
created_at TIMESTAMPTZ DEFAULT now() NOT NULL,
|
|
||||||
updated_at TIMESTAMPTZ DEFAULT now() NOT NULL
|
|
||||||
);
|
|
||||||
COMMENT ON TABLE public.products IS 'Represents a specific, sellable product, combining a generic item with a brand and size.';
|
|
||||||
COMMENT ON COLUMN public.products.upc_code IS 'Universal Product Code, if available, for exact product matching.';
|
|
||||||
COMMENT ON COLUMN public.products.brand_id IS 'Can be null for generic/store-brand items.';
|
|
||||||
COMMENT ON COLUMN public.products.name IS 'Prime Raised without Antibiotics Chicken Breast.';
|
|
||||||
COMMENT ON COLUMN public.products.size IS 'e.g., "4L", "500g".';
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_products_master_item_id ON public.products(master_item_id);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_products_brand_id ON public.products(brand_id);
|
|
||||||
|
|
||||||
-- 25. Linking table for when one flyer is valid for multiple locations.
|
-- 25. Linking table for when one flyer is valid for multiple locations.
|
||||||
CREATE TABLE IF NOT EXISTS public.flyer_locations (
|
CREATE TABLE IF NOT EXISTS public.flyer_locations (
|
||||||
flyer_id BIGINT NOT NULL REFERENCES public.flyers(flyer_id) ON DELETE CASCADE,
|
flyer_id BIGINT NOT NULL REFERENCES public.flyers(flyer_id) ON DELETE CASCADE,
|
||||||
@@ -510,7 +510,7 @@ CREATE UNIQUE INDEX IF NOT EXISTS idx_recipes_unique_system_recipe_name ON publi
|
|||||||
CREATE TABLE IF NOT EXISTS public.recipe_ingredients (
|
CREATE TABLE IF NOT EXISTS public.recipe_ingredients (
|
||||||
recipe_ingredient_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
|
recipe_ingredient_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
|
||||||
recipe_id BIGINT NOT NULL REFERENCES public.recipes(recipe_id) ON DELETE CASCADE,
|
recipe_id BIGINT NOT NULL REFERENCES public.recipes(recipe_id) ON DELETE CASCADE,
|
||||||
master_item_id BIGINT NOT NULL REFERENCES public.master_grocery_items(master_grocery_item_id),
|
master_item_id BIGINT NOT NULL REFERENCES public.master_grocery_items(master_grocery_item_id) ON DELETE CASCADE,
|
||||||
quantity NUMERIC NOT NULL,
|
quantity NUMERIC NOT NULL,
|
||||||
unit TEXT NOT NULL,
|
unit TEXT NOT NULL,
|
||||||
created_at TIMESTAMPTZ DEFAULT now() NOT NULL,
|
created_at TIMESTAMPTZ DEFAULT now() NOT NULL,
|
||||||
@@ -796,7 +796,7 @@ CREATE INDEX IF NOT EXISTS idx_shopping_trips_shopping_list_id ON public.shoppin
|
|||||||
CREATE TABLE IF NOT EXISTS public.shopping_trip_items (
|
CREATE TABLE IF NOT EXISTS public.shopping_trip_items (
|
||||||
shopping_trip_item_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
|
shopping_trip_item_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
|
||||||
shopping_trip_id BIGINT NOT NULL REFERENCES public.shopping_trips(shopping_trip_id) ON DELETE CASCADE,
|
shopping_trip_id BIGINT NOT NULL REFERENCES public.shopping_trips(shopping_trip_id) ON DELETE CASCADE,
|
||||||
master_item_id BIGINT REFERENCES public.master_grocery_items(master_grocery_item_id),
|
master_item_id BIGINT REFERENCES public.master_grocery_items(master_grocery_item_id) ON DELETE SET NULL,
|
||||||
custom_item_name TEXT,
|
custom_item_name TEXT,
|
||||||
quantity NUMERIC NOT NULL,
|
quantity NUMERIC NOT NULL,
|
||||||
price_paid_cents INTEGER,
|
price_paid_cents INTEGER,
|
||||||
@@ -862,7 +862,7 @@ CREATE INDEX IF NOT EXISTS idx_user_follows_following_id ON public.user_follows(
|
|||||||
CREATE TABLE IF NOT EXISTS public.receipts (
|
CREATE TABLE IF NOT EXISTS public.receipts (
|
||||||
receipt_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
|
receipt_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
|
||||||
user_id UUID NOT NULL REFERENCES public.users(user_id) ON DELETE CASCADE,
|
user_id UUID NOT NULL REFERENCES public.users(user_id) ON DELETE CASCADE,
|
||||||
store_id BIGINT REFERENCES public.stores(store_id),
|
store_id BIGINT REFERENCES public.stores(store_id) ON DELETE CASCADE,
|
||||||
receipt_image_url TEXT NOT NULL,
|
receipt_image_url TEXT NOT NULL,
|
||||||
transaction_date TIMESTAMPTZ,
|
transaction_date TIMESTAMPTZ,
|
||||||
total_amount_cents INTEGER,
|
total_amount_cents INTEGER,
|
||||||
@@ -883,8 +883,8 @@ CREATE TABLE IF NOT EXISTS public.receipt_items (
|
|||||||
raw_item_description TEXT NOT NULL,
|
raw_item_description TEXT NOT NULL,
|
||||||
quantity NUMERIC DEFAULT 1 NOT NULL,
|
quantity NUMERIC DEFAULT 1 NOT NULL,
|
||||||
price_paid_cents INTEGER NOT NULL,
|
price_paid_cents INTEGER NOT NULL,
|
||||||
master_item_id BIGINT REFERENCES public.master_grocery_items(master_grocery_item_id),
|
master_item_id BIGINT REFERENCES public.master_grocery_items(master_grocery_item_id) ON DELETE SET NULL,
|
||||||
product_id BIGINT REFERENCES public.products(product_id),
|
product_id BIGINT REFERENCES public.products(product_id) ON DELETE SET NULL,
|
||||||
status TEXT DEFAULT 'unmatched' NOT NULL CHECK (status IN ('unmatched', 'matched', 'needs_review', 'ignored')),
|
status TEXT DEFAULT 'unmatched' NOT NULL CHECK (status IN ('unmatched', 'matched', 'needs_review', 'ignored')),
|
||||||
created_at TIMESTAMPTZ DEFAULT now() NOT NULL,
|
created_at TIMESTAMPTZ DEFAULT now() NOT NULL,
|
||||||
updated_at TIMESTAMPTZ DEFAULT now() NOT NULL
|
updated_at TIMESTAMPTZ DEFAULT now() NOT NULL
|
||||||
@@ -2128,11 +2128,61 @@ DROP FUNCTION IF EXISTS public.fork_recipe(UUID, BIGINT);
|
|||||||
|
|
||||||
CREATE OR REPLACE FUNCTION public.fork_recipe(p_user_id UUID, p_original_recipe_id BIGINT)
|
CREATE OR REPLACE FUNCTION public.fork_recipe(p_user_id UUID, p_original_recipe_id BIGINT)
|
||||||
RETURNS SETOF public.recipes
|
RETURNS SETOF public.recipes
|
||||||
LANGUAGE sql
|
LANGUAGE plpgsql
|
||||||
SECURITY INVOKER
|
SECURITY INVOKER
|
||||||
AS $$
|
AS $$
|
||||||
-- The entire forking logic is now encapsulated in a single, atomic database function.
|
DECLARE
|
||||||
SELECT * FROM public.fork_recipe(p_user_id, p_original_recipe_id);
|
new_recipe_id BIGINT;
|
||||||
|
BEGIN
|
||||||
|
-- 1. Create a copy of the recipe, linking it to the new user and the original recipe.
|
||||||
|
INSERT INTO public.recipes (
|
||||||
|
user_id,
|
||||||
|
original_recipe_id,
|
||||||
|
name,
|
||||||
|
description,
|
||||||
|
instructions,
|
||||||
|
prep_time_minutes,
|
||||||
|
cook_time_minutes,
|
||||||
|
servings,
|
||||||
|
photo_url,
|
||||||
|
calories_per_serving,
|
||||||
|
protein_grams,
|
||||||
|
fat_grams,
|
||||||
|
carb_grams,
|
||||||
|
status -- Forked recipes should be private by default
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
p_user_id,
|
||||||
|
p_original_recipe_id,
|
||||||
|
original.name || ' (Fork)', -- Append '(Fork)' to distinguish it
|
||||||
|
original.description,
|
||||||
|
original.instructions,
|
||||||
|
original.prep_time_minutes,
|
||||||
|
original.cook_time_minutes,
|
||||||
|
original.servings,
|
||||||
|
original.photo_url,
|
||||||
|
original.calories_per_serving,
|
||||||
|
original.protein_grams,
|
||||||
|
original.fat_grams,
|
||||||
|
original.carb_grams,
|
||||||
|
'private'
|
||||||
|
FROM public.recipes AS original
|
||||||
|
WHERE original.recipe_id = p_original_recipe_id
|
||||||
|
RETURNING recipe_id INTO new_recipe_id;
|
||||||
|
|
||||||
|
-- If the original recipe didn't exist, new_recipe_id will be null.
|
||||||
|
IF new_recipe_id IS NULL THEN
|
||||||
|
RETURN;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- 2. Copy all ingredients, tags, and appliances from the original recipe to the new one.
|
||||||
|
INSERT INTO public.recipe_ingredients (recipe_id, master_item_id, quantity, unit) SELECT new_recipe_id, master_item_id, quantity, unit FROM public.recipe_ingredients WHERE recipe_id = p_original_recipe_id;
|
||||||
|
INSERT INTO public.recipe_tags (recipe_id, tag_id) SELECT new_recipe_id, tag_id FROM public.recipe_tags WHERE recipe_id = p_original_recipe_id;
|
||||||
|
INSERT INTO public.recipe_appliances (recipe_id, appliance_id) SELECT new_recipe_id, appliance_id FROM public.recipe_appliances WHERE recipe_id = p_original_recipe_id;
|
||||||
|
|
||||||
|
-- 3. Return the newly created recipe record.
|
||||||
|
RETURN QUERY SELECT * FROM public.recipes WHERE recipe_id = new_recipe_id;
|
||||||
|
END;
|
||||||
$$;
|
$$;
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
231
src/App.test.tsx
231
src/App.test.tsx
@@ -7,9 +7,19 @@ import App from './App';
|
|||||||
import * as aiApiClient from './services/aiApiClient'; // Import aiApiClient
|
import * as aiApiClient from './services/aiApiClient'; // Import aiApiClient
|
||||||
import * as apiClient from './services/apiClient';
|
import * as apiClient from './services/apiClient';
|
||||||
import { AppProviders } from './providers/AppProviders';
|
import { AppProviders } from './providers/AppProviders';
|
||||||
import type { Flyer, UserProfile} from './types';
|
import type { Flyer, UserProfile } from './types';
|
||||||
import { createMockFlyer, createMockUserProfile, createMockUser } from './tests/utils/mockFactories';
|
import {
|
||||||
import { mockUseAuth, mockUseFlyers, mockUseMasterItems, mockUseUserData, mockUseFlyerItems } from './tests/setup/mockHooks';
|
createMockFlyer,
|
||||||
|
createMockUserProfile,
|
||||||
|
createMockUser,
|
||||||
|
} from './tests/utils/mockFactories';
|
||||||
|
import {
|
||||||
|
mockUseAuth,
|
||||||
|
mockUseFlyers,
|
||||||
|
mockUseMasterItems,
|
||||||
|
mockUseUserData,
|
||||||
|
mockUseFlyerItems,
|
||||||
|
} from './tests/setup/mockHooks';
|
||||||
|
|
||||||
// Mock top-level components rendered by App's routes
|
// Mock top-level components rendered by App's routes
|
||||||
|
|
||||||
@@ -26,7 +36,7 @@ vi.mock('pdfjs-dist', () => ({
|
|||||||
// Mock the new config module
|
// Mock the new config module
|
||||||
vi.mock('./config', () => ({
|
vi.mock('./config', () => ({
|
||||||
default: {
|
default: {
|
||||||
app: { version: '1.0.0', commitMessage: 'Initial commit', commitUrl: '#' },
|
app: { version: '20250101-1200:abc1234:1.0.0', commitMessage: 'Initial commit', commitUrl: '#' },
|
||||||
google: { mapsEmbedApiKey: 'mock-key' },
|
google: { mapsEmbedApiKey: 'mock-key' },
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
@@ -42,6 +52,11 @@ vi.mock('./hooks/useFlyerItems', async () => {
|
|||||||
return { useFlyerItems: hooks.mockUseFlyerItems };
|
return { useFlyerItems: hooks.mockUseFlyerItems };
|
||||||
});
|
});
|
||||||
|
|
||||||
|
vi.mock('./hooks/useAuth', async () => {
|
||||||
|
const hooks = await import('./tests/setup/mockHooks');
|
||||||
|
return { useAuth: hooks.mockUseAuth };
|
||||||
|
});
|
||||||
|
|
||||||
vi.mock('./components/Footer', async () => {
|
vi.mock('./components/Footer', async () => {
|
||||||
const { MockFooter } = await import('./tests/utils/componentMocks');
|
const { MockFooter } = await import('./tests/utils/componentMocks');
|
||||||
return { Footer: MockFooter };
|
return { Footer: MockFooter };
|
||||||
@@ -120,13 +135,19 @@ describe('App Component', () => {
|
|||||||
let storage: { [key: string]: string } = {};
|
let storage: { [key: string]: string } = {};
|
||||||
const localStorageMock = {
|
const localStorageMock = {
|
||||||
getItem: vi.fn((key: string) => storage[key] || null),
|
getItem: vi.fn((key: string) => storage[key] || null),
|
||||||
setItem: vi.fn((key: string, value: string) => { storage[key] = value; }),
|
setItem: vi.fn((key: string, value: string) => {
|
||||||
removeItem: vi.fn((key: string) => { delete storage[key]; }),
|
storage[key] = value;
|
||||||
clear: vi.fn(() => { storage = {}; }),
|
}),
|
||||||
|
removeItem: vi.fn((key: string) => {
|
||||||
|
delete storage[key];
|
||||||
|
}),
|
||||||
|
clear: vi.fn(() => {
|
||||||
|
storage = {};
|
||||||
|
}),
|
||||||
};
|
};
|
||||||
|
|
||||||
// Mock matchMedia
|
// Mock matchMedia
|
||||||
const matchMediaMock = vi.fn().mockImplementation(query => ({
|
const matchMediaMock = vi.fn().mockImplementation((query) => ({
|
||||||
matches: false, // Default to light mode
|
matches: false, // Default to light mode
|
||||||
media: query,
|
media: query,
|
||||||
onchange: null,
|
onchange: null,
|
||||||
@@ -195,20 +216,36 @@ describe('App Component', () => {
|
|||||||
// preventing "Body has already been read" errors.
|
// preventing "Body has already been read" errors.
|
||||||
// Use mockImplementation to create a new Response object for each call,
|
// Use mockImplementation to create a new Response object for each call,
|
||||||
// preventing "Body has already been read" errors.
|
// preventing "Body has already been read" errors.
|
||||||
mockedApiClient.fetchFlyers.mockImplementation(() => Promise.resolve(new Response(JSON.stringify([]))));
|
mockedApiClient.fetchFlyers.mockImplementation(() =>
|
||||||
|
Promise.resolve(new Response(JSON.stringify([]))),
|
||||||
|
);
|
||||||
// Mock getAuthenticatedUserProfile as it's called by useAuth's checkAuthToken and login
|
// Mock getAuthenticatedUserProfile as it's called by useAuth's checkAuthToken and login
|
||||||
mockedApiClient.getAuthenticatedUserProfile.mockImplementation(() => Promise.resolve(new Response(JSON.stringify(
|
mockedApiClient.getAuthenticatedUserProfile.mockImplementation(() =>
|
||||||
|
Promise.resolve(
|
||||||
|
new Response(
|
||||||
|
JSON.stringify(
|
||||||
createMockUserProfile({
|
createMockUserProfile({
|
||||||
user: { user_id: 'test-user-id', email: 'test@example.com' },
|
user: { user_id: 'test-user-id', email: 'test@example.com' },
|
||||||
full_name: 'Test User',
|
full_name: 'Test User',
|
||||||
role: 'user',
|
role: 'user',
|
||||||
points: 0,
|
points: 0,
|
||||||
})
|
}),
|
||||||
))));
|
),
|
||||||
mockedApiClient.fetchMasterItems.mockImplementation(() => Promise.resolve(new Response(JSON.stringify([]))));
|
),
|
||||||
mockedApiClient.fetchWatchedItems.mockImplementation(() => Promise.resolve(new Response(JSON.stringify([]))));
|
),
|
||||||
mockedApiClient.fetchShoppingLists.mockImplementation(() => Promise.resolve(new Response(JSON.stringify([]))));
|
);
|
||||||
mockedAiApiClient.rescanImageArea.mockResolvedValue(new Response(JSON.stringify({ text: 'mocked text' }))); // Mock for FlyerCorrectionTool
|
mockedApiClient.fetchMasterItems.mockImplementation(() =>
|
||||||
|
Promise.resolve(new Response(JSON.stringify([]))),
|
||||||
|
);
|
||||||
|
mockedApiClient.fetchWatchedItems.mockImplementation(() =>
|
||||||
|
Promise.resolve(new Response(JSON.stringify([]))),
|
||||||
|
);
|
||||||
|
mockedApiClient.fetchShoppingLists.mockImplementation(() =>
|
||||||
|
Promise.resolve(new Response(JSON.stringify([]))),
|
||||||
|
);
|
||||||
|
mockedAiApiClient.rescanImageArea.mockResolvedValue(
|
||||||
|
new Response(JSON.stringify({ text: 'mocked text' })),
|
||||||
|
); // Mock for FlyerCorrectionTool
|
||||||
console.log('[TEST DEBUG] beforeEach: Setup complete');
|
console.log('[TEST DEBUG] beforeEach: Setup complete');
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -218,7 +255,7 @@ describe('App Component', () => {
|
|||||||
<AppProviders>
|
<AppProviders>
|
||||||
<App />
|
<App />
|
||||||
</AppProviders>
|
</AppProviders>
|
||||||
</MemoryRouter>
|
</MemoryRouter>,
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -228,7 +265,9 @@ describe('App Component', () => {
|
|||||||
userProfile: null,
|
userProfile: null,
|
||||||
authStatus: 'SIGNED_OUT',
|
authStatus: 'SIGNED_OUT',
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
login: vi.fn(), logout: vi.fn(), updateProfile: vi.fn(),
|
login: vi.fn(),
|
||||||
|
logout: vi.fn(),
|
||||||
|
updateProfile: vi.fn(),
|
||||||
});
|
});
|
||||||
|
|
||||||
renderApp();
|
renderApp();
|
||||||
@@ -261,7 +300,9 @@ describe('App Component', () => {
|
|||||||
userProfile: mockAdminProfile,
|
userProfile: mockAdminProfile,
|
||||||
authStatus: 'AUTHENTICATED',
|
authStatus: 'AUTHENTICATED',
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
login: vi.fn(), logout: vi.fn(), updateProfile: vi.fn(),
|
login: vi.fn(),
|
||||||
|
logout: vi.fn(),
|
||||||
|
updateProfile: vi.fn(),
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log('[TEST DEBUG] Rendering App with /admin route');
|
console.log('[TEST DEBUG] Rendering App with /admin route');
|
||||||
@@ -280,7 +321,9 @@ describe('App Component', () => {
|
|||||||
userProfile: null,
|
userProfile: null,
|
||||||
authStatus: 'SIGNED_OUT',
|
authStatus: 'SIGNED_OUT',
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
login: vi.fn(), logout: vi.fn(), updateProfile: vi.fn(),
|
login: vi.fn(),
|
||||||
|
logout: vi.fn(),
|
||||||
|
updateProfile: vi.fn(),
|
||||||
});
|
});
|
||||||
|
|
||||||
renderApp();
|
renderApp();
|
||||||
@@ -301,7 +344,9 @@ describe('App Component', () => {
|
|||||||
userProfile: mockAdminProfile,
|
userProfile: mockAdminProfile,
|
||||||
authStatus: 'AUTHENTICATED',
|
authStatus: 'AUTHENTICATED',
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
login: vi.fn(), logout: vi.fn(), updateProfile: vi.fn(),
|
login: vi.fn(),
|
||||||
|
logout: vi.fn(),
|
||||||
|
updateProfile: vi.fn(),
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log('[TEST DEBUG] Rendering App with /admin route');
|
console.log('[TEST DEBUG] Rendering App with /admin route');
|
||||||
@@ -321,33 +366,50 @@ describe('App Component', () => {
|
|||||||
|
|
||||||
describe('Theme and Unit System Synchronization', () => {
|
describe('Theme and Unit System Synchronization', () => {
|
||||||
it('should set dark mode based on user profile preferences', async () => {
|
it('should set dark mode based on user profile preferences', async () => {
|
||||||
console.log('[TEST DEBUG] Test Start: should set dark mode based on user profile preferences');
|
console.log(
|
||||||
const profileWithDarkMode: UserProfile = createMockUserProfile({ user: createMockUser({ user_id: 'user-1', email: 'dark@mode.com' }), role: 'user', points: 0,
|
'[TEST DEBUG] Test Start: should set dark mode based on user profile preferences',
|
||||||
preferences: { darkMode: true }
|
);
|
||||||
|
const profileWithDarkMode: UserProfile = createMockUserProfile({
|
||||||
|
user: createMockUser({ user_id: 'user-1', email: 'dark@mode.com' }),
|
||||||
|
role: 'user',
|
||||||
|
points: 0,
|
||||||
|
preferences: { darkMode: true },
|
||||||
});
|
});
|
||||||
mockUseAuth.mockReturnValue({
|
mockUseAuth.mockReturnValue({
|
||||||
userProfile: profileWithDarkMode,
|
userProfile: profileWithDarkMode,
|
||||||
authStatus: 'AUTHENTICATED',
|
authStatus: 'AUTHENTICATED',
|
||||||
isLoading: false, login: vi.fn(), logout: vi.fn(), updateProfile: vi.fn(),
|
isLoading: false,
|
||||||
|
login: vi.fn(),
|
||||||
|
logout: vi.fn(),
|
||||||
|
updateProfile: vi.fn(),
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log('[TEST DEBUG] Rendering App');
|
console.log('[TEST DEBUG] Rendering App');
|
||||||
renderApp();
|
renderApp();
|
||||||
// The useEffect that sets the theme is asynchronous. We must wait for the update.
|
// The useEffect that sets the theme is asynchronous. We must wait for the update.
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
console.log('[TEST DEBUG] Checking for dark class. Current classes:', document.documentElement.className);
|
console.log(
|
||||||
|
'[TEST DEBUG] Checking for dark class. Current classes:',
|
||||||
|
document.documentElement.className,
|
||||||
|
);
|
||||||
expect(document.documentElement).toHaveClass('dark');
|
expect(document.documentElement).toHaveClass('dark');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should set light mode based on user profile preferences', async () => {
|
it('should set light mode based on user profile preferences', async () => {
|
||||||
const profileWithLightMode: UserProfile = createMockUserProfile({ user: createMockUser({ user_id: 'user-1', email: 'light@mode.com' }), role: 'user', points: 0,
|
const profileWithLightMode: UserProfile = createMockUserProfile({
|
||||||
preferences: { darkMode: false }
|
user: createMockUser({ user_id: 'user-1', email: 'light@mode.com' }),
|
||||||
|
role: 'user',
|
||||||
|
points: 0,
|
||||||
|
preferences: { darkMode: false },
|
||||||
});
|
});
|
||||||
mockUseAuth.mockReturnValue({
|
mockUseAuth.mockReturnValue({
|
||||||
userProfile: profileWithLightMode,
|
userProfile: profileWithLightMode,
|
||||||
authStatus: 'AUTHENTICATED',
|
authStatus: 'AUTHENTICATED',
|
||||||
isLoading: false, login: vi.fn(), logout: vi.fn(), updateProfile: vi.fn(),
|
isLoading: false,
|
||||||
|
login: vi.fn(),
|
||||||
|
logout: vi.fn(),
|
||||||
|
updateProfile: vi.fn(),
|
||||||
});
|
});
|
||||||
renderApp();
|
renderApp();
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
@@ -364,7 +426,7 @@ describe('App Component', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should set dark mode based on system preference if no other setting exists', async () => {
|
it('should set dark mode based on system preference if no other setting exists', async () => {
|
||||||
matchMediaMock.mockImplementationOnce(query => ({ matches: true, media: query }));
|
matchMediaMock.mockImplementationOnce((query) => ({ matches: true, media: query }));
|
||||||
renderApp();
|
renderApp();
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(document.documentElement).toHaveClass('dark');
|
expect(document.documentElement).toHaveClass('dark');
|
||||||
@@ -372,13 +434,19 @@ describe('App Component', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should set unit system based on user profile preferences', async () => {
|
it('should set unit system based on user profile preferences', async () => {
|
||||||
const profileWithMetric: UserProfile = createMockUserProfile({ user: createMockUser({ user_id: 'user-1', email: 'metric@user.com' }), role: 'user', points: 0,
|
const profileWithMetric: UserProfile = createMockUserProfile({
|
||||||
preferences: { unitSystem: 'metric' }
|
user: createMockUser({ user_id: 'user-1', email: 'metric@user.com' }),
|
||||||
|
role: 'user',
|
||||||
|
points: 0,
|
||||||
|
preferences: { unitSystem: 'metric' },
|
||||||
});
|
});
|
||||||
mockUseAuth.mockReturnValue({
|
mockUseAuth.mockReturnValue({
|
||||||
userProfile: profileWithMetric,
|
userProfile: profileWithMetric,
|
||||||
authStatus: 'AUTHENTICATED',
|
authStatus: 'AUTHENTICATED',
|
||||||
isLoading: false, login: vi.fn(), logout: vi.fn(), updateProfile: vi.fn(),
|
isLoading: false,
|
||||||
|
login: vi.fn(),
|
||||||
|
logout: vi.fn(),
|
||||||
|
updateProfile: vi.fn(),
|
||||||
});
|
});
|
||||||
|
|
||||||
renderApp();
|
renderApp();
|
||||||
@@ -394,14 +462,17 @@ describe('App Component', () => {
|
|||||||
|
|
||||||
describe('OAuth Token Handling', () => {
|
describe('OAuth Token Handling', () => {
|
||||||
it('should call login when a googleAuthToken is in the URL', async () => {
|
it('should call login when a googleAuthToken is in the URL', async () => {
|
||||||
console.log('[TEST DEBUG] Test Start: should call login when a googleAuthToken is in the URL');
|
console.log(
|
||||||
|
'[TEST DEBUG] Test Start: should call login when a googleAuthToken is in the URL',
|
||||||
|
);
|
||||||
const mockLogin = vi.fn().mockResolvedValue(undefined);
|
const mockLogin = vi.fn().mockResolvedValue(undefined);
|
||||||
mockUseAuth.mockReturnValue({
|
mockUseAuth.mockReturnValue({
|
||||||
userProfile: null,
|
userProfile: null,
|
||||||
authStatus: 'SIGNED_OUT',
|
authStatus: 'SIGNED_OUT',
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
login: mockLogin,
|
login: mockLogin,
|
||||||
logout: vi.fn(), updateProfile: vi.fn(),
|
logout: vi.fn(),
|
||||||
|
updateProfile: vi.fn(),
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log('[TEST DEBUG] Rendering App with googleAuthToken');
|
console.log('[TEST DEBUG] Rendering App with googleAuthToken');
|
||||||
@@ -414,14 +485,17 @@ describe('App Component', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should call login when a githubAuthToken is in the URL', async () => {
|
it('should call login when a githubAuthToken is in the URL', async () => {
|
||||||
console.log('[TEST DEBUG] Test Start: should call login when a githubAuthToken is in the URL');
|
console.log(
|
||||||
|
'[TEST DEBUG] Test Start: should call login when a githubAuthToken is in the URL',
|
||||||
|
);
|
||||||
const mockLogin = vi.fn().mockResolvedValue(undefined);
|
const mockLogin = vi.fn().mockResolvedValue(undefined);
|
||||||
mockUseAuth.mockReturnValue({
|
mockUseAuth.mockReturnValue({
|
||||||
userProfile: null,
|
userProfile: null,
|
||||||
authStatus: 'SIGNED_OUT',
|
authStatus: 'SIGNED_OUT',
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
login: mockLogin,
|
login: mockLogin,
|
||||||
logout: vi.fn(), updateProfile: vi.fn(),
|
logout: vi.fn(),
|
||||||
|
updateProfile: vi.fn(),
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log('[TEST DEBUG] Rendering App with githubAuthToken');
|
console.log('[TEST DEBUG] Rendering App with githubAuthToken');
|
||||||
@@ -434,14 +508,17 @@ describe('App Component', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should log an error if login with a GitHub token fails', async () => {
|
it('should log an error if login with a GitHub token fails', async () => {
|
||||||
console.log('[TEST DEBUG] Test Start: should log an error if login with a GitHub token fails');
|
console.log(
|
||||||
|
'[TEST DEBUG] Test Start: should log an error if login with a GitHub token fails',
|
||||||
|
);
|
||||||
const mockLogin = vi.fn().mockRejectedValue(new Error('GitHub login failed'));
|
const mockLogin = vi.fn().mockRejectedValue(new Error('GitHub login failed'));
|
||||||
mockUseAuth.mockReturnValue({
|
mockUseAuth.mockReturnValue({
|
||||||
userProfile: null,
|
userProfile: null,
|
||||||
authStatus: 'SIGNED_OUT',
|
authStatus: 'SIGNED_OUT',
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
login: mockLogin,
|
login: mockLogin,
|
||||||
logout: vi.fn(), updateProfile: vi.fn(),
|
logout: vi.fn(),
|
||||||
|
updateProfile: vi.fn(),
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log('[TEST DEBUG] Rendering App with githubAuthToken');
|
console.log('[TEST DEBUG] Rendering App with githubAuthToken');
|
||||||
@@ -461,7 +538,8 @@ describe('App Component', () => {
|
|||||||
authStatus: 'SIGNED_OUT',
|
authStatus: 'SIGNED_OUT',
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
login: mockLogin,
|
login: mockLogin,
|
||||||
logout: vi.fn(), updateProfile: vi.fn(),
|
logout: vi.fn(),
|
||||||
|
updateProfile: vi.fn(),
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log('[TEST DEBUG] Rendering App with googleAuthToken');
|
console.log('[TEST DEBUG] Rendering App with googleAuthToken');
|
||||||
@@ -510,11 +588,11 @@ describe('App Component', () => {
|
|||||||
// Mock the config module for this specific test
|
// Mock the config module for this specific test
|
||||||
vi.mock('./config', () => ({
|
vi.mock('./config', () => ({
|
||||||
default: {
|
default: {
|
||||||
app: { version: '1.0.1', commitMessage: 'New feature!', commitUrl: '#' },
|
app: { version: '20250101-1200:abc1234:1.0.1', commitMessage: 'New feature!', commitUrl: '#' },
|
||||||
google: { mapsEmbedApiKey: 'mock-key' },
|
google: { mapsEmbedApiKey: 'mock-key' },
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
localStorageMock.setItem('lastSeenVersion', '1.0.0');
|
localStorageMock.setItem('lastSeenVersion', '20250101-1200:abc1234:1.0.0');
|
||||||
renderApp();
|
renderApp();
|
||||||
await expect(screen.findByTestId('whats-new-modal-mock')).resolves.toBeInTheDocument();
|
await expect(screen.findByTestId('whats-new-modal-mock')).resolves.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
@@ -538,9 +616,16 @@ describe('App Component', () => {
|
|||||||
|
|
||||||
it('should open and close the VoiceAssistant modal for authenticated users', async () => {
|
it('should open and close the VoiceAssistant modal for authenticated users', async () => {
|
||||||
console.log('[TEST DEBUG] Test Start: should open and close the VoiceAssistant modal');
|
console.log('[TEST DEBUG] Test Start: should open and close the VoiceAssistant modal');
|
||||||
mockUseAuth.mockReturnValue({ userProfile: createMockUserProfile({ role: 'user', user: { user_id: '1', email: 'test@test.com' } }),
|
mockUseAuth.mockReturnValue({
|
||||||
|
userProfile: createMockUserProfile({
|
||||||
|
role: 'user',
|
||||||
|
user: { user_id: '1', email: 'test@test.com' },
|
||||||
|
}),
|
||||||
authStatus: 'AUTHENTICATED',
|
authStatus: 'AUTHENTICATED',
|
||||||
isLoading: false, login: vi.fn(), logout: vi.fn(), updateProfile: vi.fn(),
|
isLoading: false,
|
||||||
|
login: vi.fn(),
|
||||||
|
logout: vi.fn(),
|
||||||
|
updateProfile: vi.fn(),
|
||||||
});
|
});
|
||||||
console.log('[TEST DEBUG] Rendering App');
|
console.log('[TEST DEBUG] Rendering App');
|
||||||
renderApp();
|
renderApp();
|
||||||
@@ -585,15 +670,22 @@ describe('App Component', () => {
|
|||||||
|
|
||||||
it('should render admin sub-routes correctly', async () => {
|
it('should render admin sub-routes correctly', async () => {
|
||||||
console.log('[TEST DEBUG] Test Start: should render admin sub-routes correctly');
|
console.log('[TEST DEBUG] Test Start: should render admin sub-routes correctly');
|
||||||
const mockAdminProfile: UserProfile = createMockUserProfile({ user: { user_id: 'admin-id', email: 'admin@example.com' }, role: 'admin',
|
const mockAdminProfile: UserProfile = createMockUserProfile({
|
||||||
|
user: { user_id: 'admin-id', email: 'admin@example.com' },
|
||||||
|
role: 'admin',
|
||||||
});
|
});
|
||||||
mockUseAuth.mockReturnValue({
|
mockUseAuth.mockReturnValue({
|
||||||
userProfile: mockAdminProfile,
|
userProfile: mockAdminProfile,
|
||||||
authStatus: 'AUTHENTICATED',
|
authStatus: 'AUTHENTICATED',
|
||||||
isLoading: false, login: vi.fn(), logout: vi.fn(), updateProfile: vi.fn(),
|
isLoading: false,
|
||||||
|
login: vi.fn(),
|
||||||
|
logout: vi.fn(),
|
||||||
|
updateProfile: vi.fn(),
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log('Testing admin sub-routes with renderApp wrapper to ensure ModalProvider context');
|
console.log(
|
||||||
|
'Testing admin sub-routes with renderApp wrapper to ensure ModalProvider context',
|
||||||
|
);
|
||||||
console.log('[TEST DEBUG] Rendering App with /admin/corrections');
|
console.log('[TEST DEBUG] Rendering App with /admin/corrections');
|
||||||
renderApp(['/admin/corrections']);
|
renderApp(['/admin/corrections']);
|
||||||
|
|
||||||
@@ -614,7 +706,11 @@ describe('App Component', () => {
|
|||||||
|
|
||||||
renderApp();
|
renderApp();
|
||||||
fireEvent.click(screen.getByText('Open Correction Tool'));
|
fireEvent.click(screen.getByText('Open Correction Tool'));
|
||||||
const correctionTool = await screen.findByTestId('flyer-correction-tool-mock', {}, { timeout: 2000 });
|
const correctionTool = await screen.findByTestId(
|
||||||
|
'flyer-correction-tool-mock',
|
||||||
|
{},
|
||||||
|
{ timeout: 2000 },
|
||||||
|
);
|
||||||
// We trigger the callback from the mock and ensure it doesn't crash.
|
// We trigger the callback from the mock and ensure it doesn't crash.
|
||||||
fireEvent.click(within(correctionTool).getByText('Extract Store'));
|
fireEvent.click(within(correctionTool).getByText('Extract Store'));
|
||||||
// The test passes if no errors are thrown here.
|
// The test passes if no errors are thrown here.
|
||||||
@@ -629,18 +725,26 @@ describe('App Component', () => {
|
|||||||
|
|
||||||
renderApp();
|
renderApp();
|
||||||
fireEvent.click(screen.getByText('Open Correction Tool'));
|
fireEvent.click(screen.getByText('Open Correction Tool'));
|
||||||
const correctionTool = await screen.findByTestId('flyer-correction-tool-mock', {}, { timeout: 2000 });
|
const correctionTool = await screen.findByTestId(
|
||||||
|
'flyer-correction-tool-mock',
|
||||||
|
{},
|
||||||
|
{ timeout: 2000 },
|
||||||
|
);
|
||||||
fireEvent.click(within(correctionTool).getByText('Extract Dates'));
|
fireEvent.click(within(correctionTool).getByText('Extract Dates'));
|
||||||
// The test passes if no errors are thrown here, covering the 'dates' branch.
|
// The test passes if no errors are thrown here, covering the 'dates' branch.
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('Version Display and What\'s New', () => {
|
describe("Version Display and What's New", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
// Also mock the config module to reflect this change
|
// Also mock the config module to reflect this change
|
||||||
vi.mock('./config', () => ({
|
vi.mock('./config', () => ({
|
||||||
default: {
|
default: {
|
||||||
app: { version: '2.0.0', commitMessage: 'A new version!', commitUrl: 'http://example.com/commit/2.0.0' },
|
app: {
|
||||||
|
version: '20250101-1200:abc1234:2.0.0',
|
||||||
|
commitMessage: 'A new version!',
|
||||||
|
commitUrl: 'http://example.com/commit/2.0.0',
|
||||||
|
},
|
||||||
google: { mapsEmbedApiKey: 'mock-key' },
|
google: { mapsEmbedApiKey: 'mock-key' },
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
@@ -648,14 +752,14 @@ describe('App Component', () => {
|
|||||||
|
|
||||||
it('should display the version number and commit link', () => {
|
it('should display the version number and commit link', () => {
|
||||||
renderApp();
|
renderApp();
|
||||||
const versionLink = screen.getByText(`Version: 2.0.0`);
|
const versionLink = screen.getByText(`Version: 20250101-1200:abc1234:2.0.0`);
|
||||||
expect(versionLink).toBeInTheDocument();
|
expect(versionLink).toBeInTheDocument();
|
||||||
expect(versionLink).toHaveAttribute('href', 'http://example.com/commit/2.0.0');
|
expect(versionLink).toHaveAttribute('href', 'http://example.com/commit/2.0.0');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should open the "What\'s New" modal when the question mark icon is clicked', async () => {
|
it('should open the "What\'s New" modal when the question mark icon is clicked', async () => {
|
||||||
// Pre-set the localStorage to prevent the modal from opening automatically
|
// Pre-set the localStorage to prevent the modal from opening automatically
|
||||||
localStorageMock.setItem('lastSeenVersion', '2.0.0');
|
localStorageMock.setItem('lastSeenVersion', '20250101-1200:abc1234:2.0.0');
|
||||||
|
|
||||||
renderApp();
|
renderApp();
|
||||||
expect(screen.queryByTestId('whats-new-modal-mock')).not.toBeInTheDocument();
|
expect(screen.queryByTestId('whats-new-modal-mock')).not.toBeInTheDocument();
|
||||||
@@ -691,10 +795,16 @@ describe('App Component', () => {
|
|||||||
|
|
||||||
describe('Profile and Login Handlers', () => {
|
describe('Profile and Login Handlers', () => {
|
||||||
it('should call updateProfile when handleProfileUpdate is triggered', async () => {
|
it('should call updateProfile when handleProfileUpdate is triggered', async () => {
|
||||||
console.log('[TEST DEBUG] Test Start: should call updateProfile when handleProfileUpdate is triggered');
|
console.log(
|
||||||
|
'[TEST DEBUG] Test Start: should call updateProfile when handleProfileUpdate is triggered',
|
||||||
|
);
|
||||||
const mockUpdateProfile = vi.fn();
|
const mockUpdateProfile = vi.fn();
|
||||||
// To test profile updates, the user must be authenticated to see the "Update Profile" button.
|
// To test profile updates, the user must be authenticated to see the "Update Profile" button.
|
||||||
mockUseAuth.mockReturnValue({ userProfile: createMockUserProfile({ user: { user_id: 'test-user', email: 'test@example.com' }, role: 'user' }),
|
mockUseAuth.mockReturnValue({
|
||||||
|
userProfile: createMockUserProfile({
|
||||||
|
user: { user_id: 'test-user', email: 'test@example.com' },
|
||||||
|
role: 'user',
|
||||||
|
}),
|
||||||
authStatus: 'AUTHENTICATED',
|
authStatus: 'AUTHENTICATED',
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
login: vi.fn(),
|
login: vi.fn(),
|
||||||
@@ -712,19 +822,24 @@ describe('App Component', () => {
|
|||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
console.log('[TEST DEBUG] Checking mockUpdateProfile calls:', mockUpdateProfile.mock.calls);
|
console.log('[TEST DEBUG] Checking mockUpdateProfile calls:', mockUpdateProfile.mock.calls);
|
||||||
expect(mockUpdateProfile).toHaveBeenCalledWith(expect.objectContaining({ full_name: 'Updated' }));
|
expect(mockUpdateProfile).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ full_name: 'Updated' }),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should set an error state if login fails inside handleLoginSuccess', async () => {
|
it('should set an error state if login fails inside handleLoginSuccess', async () => {
|
||||||
console.log('[TEST DEBUG] Test Start: should set an error state if login fails inside handleLoginSuccess');
|
console.log(
|
||||||
|
'[TEST DEBUG] Test Start: should set an error state if login fails inside handleLoginSuccess',
|
||||||
|
);
|
||||||
const mockLogin = vi.fn().mockRejectedValue(new Error('Login failed'));
|
const mockLogin = vi.fn().mockRejectedValue(new Error('Login failed'));
|
||||||
mockUseAuth.mockReturnValue({
|
mockUseAuth.mockReturnValue({
|
||||||
userProfile: null,
|
userProfile: null,
|
||||||
authStatus: 'SIGNED_OUT',
|
authStatus: 'SIGNED_OUT',
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
login: mockLogin,
|
login: mockLogin,
|
||||||
logout: vi.fn(), updateProfile: vi.fn()
|
logout: vi.fn(),
|
||||||
|
updateProfile: vi.fn(),
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log('[TEST DEBUG] Rendering App');
|
console.log('[TEST DEBUG] Rendering App');
|
||||||
|
|||||||
@@ -15,7 +15,12 @@ describe('AchievementsList', () => {
|
|||||||
icon: 'chef-hat',
|
icon: 'chef-hat',
|
||||||
points_value: 25,
|
points_value: 25,
|
||||||
}),
|
}),
|
||||||
createMockUserAchievement({ achievement_id: 2, name: 'List Maker', icon: 'list', points_value: 15 }),
|
createMockUserAchievement({
|
||||||
|
achievement_id: 2,
|
||||||
|
name: 'List Maker',
|
||||||
|
icon: 'list',
|
||||||
|
points_value: 15,
|
||||||
|
}),
|
||||||
createMockUserAchievement({ achievement_id: 3, name: 'Unknown Achievement', icon: 'star' }), // This icon is not in the component's map
|
createMockUserAchievement({ achievement_id: 3, name: 'Unknown Achievement', icon: 'star' }), // This icon is not in the component's map
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -40,6 +45,8 @@ describe('AchievementsList', () => {
|
|||||||
|
|
||||||
it('should render a message when there are no achievements', () => {
|
it('should render a message when there are no achievements', () => {
|
||||||
render(<AchievementsList achievements={[]} />);
|
render(<AchievementsList achievements={[]} />);
|
||||||
expect(screen.getByText('No achievements earned yet. Keep exploring to unlock them!')).toBeInTheDocument();
|
expect(
|
||||||
|
screen.getByText('No achievements earned yet. Keep exploring to unlock them!'),
|
||||||
|
).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -13,8 +13,8 @@ const Icon: React.FC<{ name: string | null | undefined }> = ({ name }) => {
|
|||||||
const iconMap: { [key: string]: string } = {
|
const iconMap: { [key: string]: string } = {
|
||||||
'chef-hat': '🧑🍳',
|
'chef-hat': '🧑🍳',
|
||||||
'share-2': '🤝',
|
'share-2': '🤝',
|
||||||
'list': '📋',
|
list: '📋',
|
||||||
'heart': '❤️',
|
heart: '❤️',
|
||||||
'git-fork': '🍴',
|
'git-fork': '🍴',
|
||||||
'piggy-bank': '🐷',
|
'piggy-bank': '🐷',
|
||||||
};
|
};
|
||||||
@@ -32,14 +32,19 @@ export const AchievementsList: React.FC<AchievementsListProps> = ({ achievements
|
|||||||
{achievements.length > 0 ? (
|
{achievements.length > 0 ? (
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
{achievements.map((ach) => (
|
{achievements.map((ach) => (
|
||||||
<div key={ach.achievement_id} className="bg-white dark:bg-gray-800 p-4 rounded-lg shadow-md flex items-start space-x-4">
|
<div
|
||||||
|
key={ach.achievement_id}
|
||||||
|
className="bg-white dark:bg-gray-800 p-4 rounded-lg shadow-md flex items-start space-x-4"
|
||||||
|
>
|
||||||
<div className="shrink-0">
|
<div className="shrink-0">
|
||||||
<Icon name={ach.icon} />
|
<Icon name={ach.icon} />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h3 className="font-bold">{ach.name}</h3>
|
<h3 className="font-bold">{ach.name}</h3>
|
||||||
<p className="text-sm text-gray-600 dark:text-gray-300">{ach.description}</p>
|
<p className="text-sm text-gray-600 dark:text-gray-300">{ach.description}</p>
|
||||||
<p className="text-xs text-yellow-500 font-semibold mt-1">+{ach.points_value} Points</p>
|
<p className="text-xs text-yellow-500 font-semibold mt-1">
|
||||||
|
+{ach.points_value} Points
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ const renderWithRouter = (profile: Profile | null, initialPath: string) => {
|
|||||||
<Route index element={<AdminContent />} />
|
<Route index element={<AdminContent />} />
|
||||||
</Route>
|
</Route>
|
||||||
</Routes>
|
</Routes>
|
||||||
</MemoryRouter>
|
</MemoryRouter>,
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -71,7 +71,7 @@ describe('ConfirmationModal (in components)', () => {
|
|||||||
confirmButtonText="Yes, Delete"
|
confirmButtonText="Yes, Delete"
|
||||||
cancelButtonText="No, Keep"
|
cancelButtonText="No, Keep"
|
||||||
confirmButtonClass="bg-blue-500"
|
confirmButtonClass="bg-blue-500"
|
||||||
/>
|
/>,
|
||||||
);
|
);
|
||||||
const confirmButton = screen.getByRole('button', { name: 'Yes, Delete' });
|
const confirmButton = screen.getByRole('button', { name: 'Yes, Delete' });
|
||||||
expect(confirmButton).toBeInTheDocument();
|
expect(confirmButton).toBeInTheDocument();
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ export const ConfirmationModal: React.FC<ConfirmationModalProps> = ({
|
|||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className="bg-white dark:bg-gray-800 rounded-lg shadow-xl w-full max-w-md relative"
|
className="bg-white dark:bg-gray-800 rounded-lg shadow-xl w-full max-w-md relative"
|
||||||
onClick={e => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
@@ -47,10 +47,16 @@ export const ConfirmationModal: React.FC<ConfirmationModalProps> = ({
|
|||||||
<div className="p-6">
|
<div className="p-6">
|
||||||
<div className="sm:flex sm:items-start">
|
<div className="sm:flex sm:items-start">
|
||||||
<div className="mx-auto shrink-0 flex items-center justify-center h-12 w-12 rounded-full bg-red-100 dark:bg-red-900/30 sm:mx-0 sm:h-10 sm:w-10">
|
<div className="mx-auto shrink-0 flex items-center justify-center h-12 w-12 rounded-full bg-red-100 dark:bg-red-900/30 sm:mx-0 sm:h-10 sm:w-10">
|
||||||
<ExclamationTriangleIcon className="h-6 w-6 text-red-600 dark:text-red-400" aria-hidden="true" />
|
<ExclamationTriangleIcon
|
||||||
|
className="h-6 w-6 text-red-600 dark:text-red-400"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left">
|
<div className="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left">
|
||||||
<h3 className="text-lg leading-6 font-medium text-gray-900 dark:text-white" id="modal-title">
|
<h3
|
||||||
|
className="text-lg leading-6 font-medium text-gray-900 dark:text-white"
|
||||||
|
id="modal-title"
|
||||||
|
>
|
||||||
{title}
|
{title}
|
||||||
</h3>
|
</h3>
|
||||||
<div className="mt-2">
|
<div className="mt-2">
|
||||||
@@ -60,8 +66,20 @@ export const ConfirmationModal: React.FC<ConfirmationModalProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="bg-gray-50 dark:bg-gray-800/50 px-4 py-3 sm:px-6 sm:flex sm:flex-row-reverse rounded-b-lg">
|
<div className="bg-gray-50 dark:bg-gray-800/50 px-4 py-3 sm:px-6 sm:flex sm:flex-row-reverse rounded-b-lg">
|
||||||
<button type="button" className={`w-full inline-flex justify-center rounded-md border border-transparent shadow-sm px-4 py-2 text-base font-medium text-white focus:outline-none focus:ring-2 focus:ring-offset-2 sm:ml-3 sm:w-auto sm:text-sm ${confirmButtonClass}`} onClick={onConfirm}>{confirmButtonText}</button>
|
<button
|
||||||
<button type="button" className="mt-3 w-full inline-flex justify-center rounded-md border border-gray-300 dark:border-gray-500 shadow-sm px-4 py-2 bg-white dark:bg-gray-700 text-base font-medium text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 sm:mt-0 sm:w-auto sm:text-sm" onClick={onClose}>{cancelButtonText}</button>
|
type="button"
|
||||||
|
className={`w-full inline-flex justify-center rounded-md border border-transparent shadow-sm px-4 py-2 text-base font-medium text-white focus:outline-none focus:ring-2 focus:ring-offset-2 sm:ml-3 sm:w-auto sm:text-sm ${confirmButtonClass}`}
|
||||||
|
onClick={onConfirm}
|
||||||
|
>
|
||||||
|
{confirmButtonText}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="mt-3 w-full inline-flex justify-center rounded-md border border-gray-300 dark:border-gray-500 shadow-sm px-4 py-2 bg-white dark:bg-gray-700 text-base font-medium text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 sm:mt-0 sm:w-auto sm:text-sm"
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
{cancelButtonText}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -10,7 +10,11 @@ interface DarkModeToggleProps {
|
|||||||
|
|
||||||
export const DarkModeToggle: React.FC<DarkModeToggleProps> = ({ isDarkMode, onToggle }) => {
|
export const DarkModeToggle: React.FC<DarkModeToggleProps> = ({ isDarkMode, onToggle }) => {
|
||||||
return (
|
return (
|
||||||
<label htmlFor="dark-mode-toggle" className="flex items-center cursor-pointer" title={isDarkMode ? 'Switch to Light Mode' : 'Switch to Dark Mode'}>
|
<label
|
||||||
|
htmlFor="dark-mode-toggle"
|
||||||
|
className="flex items-center cursor-pointer"
|
||||||
|
title={isDarkMode ? 'Switch to Light Mode' : 'Switch to Dark Mode'}
|
||||||
|
>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<input
|
<input
|
||||||
id="dark-mode-toggle"
|
id="dark-mode-toggle"
|
||||||
@@ -20,8 +24,14 @@ export const DarkModeToggle: React.FC<DarkModeToggleProps> = ({ isDarkMode, onTo
|
|||||||
onChange={onToggle}
|
onChange={onToggle}
|
||||||
/>
|
/>
|
||||||
<div className="block bg-gray-200 dark:bg-gray-700 w-14 h-8 rounded-full transition-colors"></div>
|
<div className="block bg-gray-200 dark:bg-gray-700 w-14 h-8 rounded-full transition-colors"></div>
|
||||||
<div className={`dot absolute left-1 top-1 bg-white dark:bg-gray-800 border-transparent w-6 h-6 rounded-full transition-transform duration-300 ease-in-out flex items-center justify-center ${isDarkMode ? 'transform translate-x-6' : ''}`}>
|
<div
|
||||||
{isDarkMode ? <MoonIcon className="w-4 h-4 text-yellow-300" /> : <SunIcon className="w-4 h-4 text-yellow-500" />}
|
className={`dot absolute left-1 top-1 bg-white dark:bg-gray-800 border-transparent w-6 h-6 rounded-full transition-transform duration-300 ease-in-out flex items-center justify-center ${isDarkMode ? 'transform translate-x-6' : ''}`}
|
||||||
|
>
|
||||||
|
{isDarkMode ? (
|
||||||
|
<MoonIcon className="w-4 h-4 text-yellow-300" />
|
||||||
|
) : (
|
||||||
|
<SunIcon className="w-4 h-4 text-yellow-500" />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</label>
|
</label>
|
||||||
|
|||||||
@@ -8,10 +8,12 @@ interface ErrorDisplayProps {
|
|||||||
export const ErrorDisplay: React.FC<ErrorDisplayProps> = ({ message }) => {
|
export const ErrorDisplay: React.FC<ErrorDisplayProps> = ({ message }) => {
|
||||||
if (!message) return null;
|
if (!message) return null;
|
||||||
return (
|
return (
|
||||||
<div className="bg-red-100 dark:bg-red-900/50 border border-red-400 dark:border-red-600 text-red-700 dark:text-red-300 px-4 py-3 rounded-lg relative" role="alert">
|
<div
|
||||||
|
className="bg-red-100 dark:bg-red-900/50 border border-red-400 dark:border-red-600 text-red-700 dark:text-red-300 px-4 py-3 rounded-lg relative"
|
||||||
|
role="alert"
|
||||||
|
>
|
||||||
<strong className="font-bold">Error: </strong>
|
<strong className="font-bold">Error: </strong>
|
||||||
<span className="block sm:inline">{message}</span>
|
<span className="block sm:inline">{message}</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -35,7 +35,7 @@ describe('FlyerCorrectionTool', () => {
|
|||||||
|
|
||||||
// Mock global fetch for fetching the image blob inside the component
|
// Mock global fetch for fetching the image blob inside the component
|
||||||
global.fetch = vi.fn(() =>
|
global.fetch = vi.fn(() =>
|
||||||
Promise.resolve(new Response(new Blob(['dummy-image-content'], { type: 'image/jpeg' })))
|
Promise.resolve(new Response(new Blob(['dummy-image-content'], { type: 'image/jpeg' }))),
|
||||||
) as Mocked<typeof fetch>;
|
) as Mocked<typeof fetch>;
|
||||||
|
|
||||||
// Mock canvas methods for jsdom environment
|
// Mock canvas methods for jsdom environment
|
||||||
@@ -109,7 +109,7 @@ describe('FlyerCorrectionTool', () => {
|
|||||||
// 1. Create a controllable promise for the mock.
|
// 1. Create a controllable promise for the mock.
|
||||||
console.log('--- [TEST LOG] ---: 1. Setting up controllable promise for rescanImageArea.');
|
console.log('--- [TEST LOG] ---: 1. Setting up controllable promise for rescanImageArea.');
|
||||||
let resolveRescanPromise: (value: Response | PromiseLike<Response>) => void;
|
let resolveRescanPromise: (value: Response | PromiseLike<Response>) => void;
|
||||||
const rescanPromise = new Promise<Response>(resolve => {
|
const rescanPromise = new Promise<Response>((resolve) => {
|
||||||
resolveRescanPromise = resolve;
|
resolveRescanPromise = resolve;
|
||||||
});
|
});
|
||||||
mockedAiApiClient.rescanImageArea.mockReturnValue(rescanPromise);
|
mockedAiApiClient.rescanImageArea.mockReturnValue(rescanPromise);
|
||||||
@@ -162,7 +162,7 @@ describe('FlyerCorrectionTool', () => {
|
|||||||
expect.any(File),
|
expect.any(File),
|
||||||
// 10*2=20, 10*2=20, (60-10)*2=100, (30-10)*2=40
|
// 10*2=20, 10*2=20, (60-10)*2=100, (30-10)*2=40
|
||||||
{ x: 20, y: 20, width: 100, height: 40 },
|
{ x: 20, y: 20, width: 100, height: 40 },
|
||||||
'store_name'
|
'store_name',
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
console.log('--- [TEST LOG] ---: 4b. SUCCESS: API call verified.');
|
console.log('--- [TEST LOG] ---: 4b. SUCCESS: API call verified.');
|
||||||
@@ -178,7 +178,9 @@ describe('FlyerCorrectionTool', () => {
|
|||||||
// 6. Assert the final state after the promise has resolved.
|
// 6. Assert the final state after the promise has resolved.
|
||||||
console.log('--- [TEST LOG] ---: 6. Awaiting final state assertions...');
|
console.log('--- [TEST LOG] ---: 6. Awaiting final state assertions...');
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
console.log('--- [TEST LOG] ---: 6a. waitFor check: Verifying notifications and callbacks...');
|
console.log(
|
||||||
|
'--- [TEST LOG] ---: 6a. waitFor check: Verifying notifications and callbacks...',
|
||||||
|
);
|
||||||
expect(mockedNotifySuccess).toHaveBeenCalledWith('Extracted: Super Store');
|
expect(mockedNotifySuccess).toHaveBeenCalledWith('Extracted: Super Store');
|
||||||
expect(defaultProps.onDataExtracted).toHaveBeenCalledWith('store_name', 'Super Store');
|
expect(defaultProps.onDataExtracted).toHaveBeenCalledWith('store_name', 'Super Store');
|
||||||
expect(defaultProps.onClose).toHaveBeenCalledTimes(1);
|
expect(defaultProps.onClose).toHaveBeenCalledTimes(1);
|
||||||
@@ -198,7 +200,9 @@ describe('FlyerCorrectionTool', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should show an error if rescan is attempted before image is loaded', async () => {
|
it('should show an error if rescan is attempted before image is loaded', async () => {
|
||||||
console.log('TEST: Starting "should show an error if rescan is attempted before image is loaded"');
|
console.log(
|
||||||
|
'TEST: Starting "should show an error if rescan is attempted before image is loaded"',
|
||||||
|
);
|
||||||
|
|
||||||
// Override fetch to be pending forever so 'imageFile' remains null
|
// Override fetch to be pending forever so 'imageFile' remains null
|
||||||
// This allows us to test the guard clause inside handleRescan while the button is enabled
|
// This allows us to test the guard clause inside handleRescan while the button is enabled
|
||||||
@@ -240,7 +244,7 @@ describe('FlyerCorrectionTool', () => {
|
|||||||
await waitFor(() => expect(global.fetch).toHaveBeenCalled());
|
await waitFor(() => expect(global.fetch).toHaveBeenCalled());
|
||||||
// Allow the promise chain in useEffect to complete
|
// Allow the promise chain in useEffect to complete
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
await new Promise(resolve => setTimeout(resolve, 0));
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
});
|
});
|
||||||
|
|
||||||
const canvas = screen.getByRole('dialog').querySelector('canvas')!;
|
const canvas = screen.getByRole('dialog').querySelector('canvas')!;
|
||||||
|
|||||||
@@ -17,7 +17,12 @@ export interface FlyerCorrectionToolProps {
|
|||||||
type Rect = { x: number; y: number; width: number; height: number };
|
type Rect = { x: number; y: number; width: number; height: number };
|
||||||
type ExtractionType = 'store_name' | 'dates';
|
type ExtractionType = 'store_name' | 'dates';
|
||||||
|
|
||||||
export const FlyerCorrectionTool: React.FC<FlyerCorrectionToolProps> = ({ isOpen, onClose, imageUrl, onDataExtracted }) => {
|
export const FlyerCorrectionTool: React.FC<FlyerCorrectionToolProps> = ({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
imageUrl,
|
||||||
|
onDataExtracted,
|
||||||
|
}) => {
|
||||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||||
const imageRef = useRef<HTMLImageElement>(null);
|
const imageRef = useRef<HTMLImageElement>(null);
|
||||||
const [isDrawing, setIsDrawing] = useState(false);
|
const [isDrawing, setIsDrawing] = useState(false);
|
||||||
@@ -31,15 +36,15 @@ export const FlyerCorrectionTool: React.FC<FlyerCorrectionToolProps> = ({ isOpen
|
|||||||
if (isOpen && imageUrl) {
|
if (isOpen && imageUrl) {
|
||||||
console.debug('[DEBUG] FlyerCorrectionTool: isOpen is true, fetching image URL:', imageUrl);
|
console.debug('[DEBUG] FlyerCorrectionTool: isOpen is true, fetching image URL:', imageUrl);
|
||||||
fetch(imageUrl)
|
fetch(imageUrl)
|
||||||
.then(res => res.blob())
|
.then((res) => res.blob())
|
||||||
.then(blob => {
|
.then((blob) => {
|
||||||
const file = new File([blob], 'flyer-image.jpg', { type: blob.type });
|
const file = new File([blob], 'flyer-image.jpg', { type: blob.type });
|
||||||
setImageFile(file);
|
setImageFile(file);
|
||||||
console.debug('[DEBUG] FlyerCorrectionTool: Image fetched and stored as File object.');
|
console.debug('[DEBUG] FlyerCorrectionTool: Image fetched and stored as File object.');
|
||||||
})
|
})
|
||||||
.catch(err => {
|
.catch((err) => {
|
||||||
console.error('[DEBUG] FlyerCorrectionTool: Failed to fetch image.', { err });
|
console.error('[DEBUG] FlyerCorrectionTool: Failed to fetch image.', { err });
|
||||||
logger.error('Failed to fetch image for correction tool', { error: err });
|
logger.error({ error: err }, 'Failed to fetch image for correction tool');
|
||||||
notifyError('Could not load the image for correction.');
|
notifyError('Could not load the image for correction.');
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -74,7 +79,9 @@ export const FlyerCorrectionTool: React.FC<FlyerCorrectionToolProps> = ({ isOpen
|
|||||||
return () => window.removeEventListener('resize', handleResize);
|
return () => window.removeEventListener('resize', handleResize);
|
||||||
}, [draw]);
|
}, [draw]);
|
||||||
|
|
||||||
const getCanvasCoordinates = (e: React.MouseEvent<HTMLCanvasElement>): { x: number; y: number } => {
|
const getCanvasCoordinates = (
|
||||||
|
e: React.MouseEvent<HTMLCanvasElement>,
|
||||||
|
): { x: number; y: number } => {
|
||||||
const canvas = canvasRef.current;
|
const canvas = canvasRef.current;
|
||||||
if (!canvas) return { x: 0, y: 0 };
|
if (!canvas) return { x: 0, y: 0 };
|
||||||
const rect = canvas.getBoundingClientRect();
|
const rect = canvas.getBoundingClientRect();
|
||||||
@@ -110,7 +117,9 @@ export const FlyerCorrectionTool: React.FC<FlyerCorrectionToolProps> = ({ isOpen
|
|||||||
|
|
||||||
const handleRescan = async (type: ExtractionType) => {
|
const handleRescan = async (type: ExtractionType) => {
|
||||||
console.debug(`[DEBUG] handleRescan triggered for type: ${type}`);
|
console.debug(`[DEBUG] handleRescan triggered for type: ${type}`);
|
||||||
console.debug(`[DEBUG] handleRescan state: selectionRect=${!!selectionRect}, imageRef=${!!imageRef.current}, imageFile=${!!imageFile}`);
|
console.debug(
|
||||||
|
`[DEBUG] handleRescan state: selectionRect=${!!selectionRect}, imageRef=${!!imageRef.current}, imageFile=${!!imageFile}`,
|
||||||
|
);
|
||||||
|
|
||||||
if (!selectionRect || !imageRef.current || !imageFile) {
|
if (!selectionRect || !imageRef.current || !imageFile) {
|
||||||
console.warn('[DEBUG] handleRescan: Guard failed. Missing prerequisites.');
|
console.warn('[DEBUG] handleRescan: Guard failed. Missing prerequisites.');
|
||||||
@@ -155,7 +164,7 @@ export const FlyerCorrectionTool: React.FC<FlyerCorrectionToolProps> = ({ isOpen
|
|||||||
const msg = err instanceof Error ? err.message : 'An unknown error occurred.';
|
const msg = err instanceof Error ? err.message : 'An unknown error occurred.';
|
||||||
console.error('[DEBUG] handleRescan: Caught an error.', { error: err });
|
console.error('[DEBUG] handleRescan: Caught an error.', { error: err });
|
||||||
notifyError(msg);
|
notifyError(msg);
|
||||||
logger.error('Error during rescan:', { error: err });
|
logger.error({ error: err }, 'Error during rescan:');
|
||||||
} finally {
|
} finally {
|
||||||
console.debug('[DEBUG] handleRescan: Finished. Setting isProcessing=false.');
|
console.debug('[DEBUG] handleRescan: Finished. Setting isProcessing=false.');
|
||||||
setIsProcessing(false);
|
setIsProcessing(false);
|
||||||
@@ -164,16 +173,40 @@ export const FlyerCorrectionTool: React.FC<FlyerCorrectionToolProps> = ({ isOpen
|
|||||||
|
|
||||||
if (!isOpen) return null;
|
if (!isOpen) return null;
|
||||||
|
|
||||||
console.debug('[DEBUG] FlyerCorrectionTool: Rendering with state:', { isProcessing, hasSelection: !!selectionRect });
|
console.debug('[DEBUG] FlyerCorrectionTool: Rendering with state:', {
|
||||||
|
isProcessing,
|
||||||
|
hasSelection: !!selectionRect,
|
||||||
|
});
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 bg-black bg-opacity-75 z-50 flex justify-center items-center p-4" onClick={onClose}>
|
<div
|
||||||
<div role="dialog" className="relative bg-gray-800 rounded-lg shadow-xl w-full max-w-6xl h-[90vh] flex flex-col" onClick={e => e.stopPropagation()}>
|
className="fixed inset-0 bg-black bg-opacity-75 z-50 flex justify-center items-center p-4"
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
role="dialog"
|
||||||
|
className="relative bg-gray-800 rounded-lg shadow-xl w-full max-w-6xl h-[90vh] flex flex-col"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
<div className="flex justify-between items-center p-4 border-b border-gray-700">
|
<div className="flex justify-between items-center p-4 border-b border-gray-700">
|
||||||
<h2 className="text-lg font-semibold text-white flex items-center"><ScissorsIcon className="w-6 h-6 mr-2" /> Flyer Correction Tool</h2>
|
<h2 className="text-lg font-semibold text-white flex items-center">
|
||||||
<button onClick={onClose} className="text-gray-400 hover:text-white" aria-label="Close correction tool"><XCircleIcon className="w-7 h-7" /></button>
|
<ScissorsIcon className="w-6 h-6 mr-2" /> Flyer Correction Tool
|
||||||
|
</h2>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="text-gray-400 hover:text-white"
|
||||||
|
aria-label="Close correction tool"
|
||||||
|
>
|
||||||
|
<XCircleIcon className="w-7 h-7" />
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="grow p-4 overflow-auto relative flex justify-center items-center">
|
<div className="grow p-4 overflow-auto relative flex justify-center items-center">
|
||||||
<img ref={imageRef} src={imageUrl} alt="Flyer for correction" className="max-w-full max-h-full object-contain" onLoad={draw} />
|
<img
|
||||||
|
ref={imageRef}
|
||||||
|
src={imageUrl}
|
||||||
|
alt="Flyer for correction"
|
||||||
|
className="max-w-full max-h-full object-contain"
|
||||||
|
onLoad={draw}
|
||||||
|
/>
|
||||||
<canvas
|
<canvas
|
||||||
ref={canvasRef}
|
ref={canvasRef}
|
||||||
className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 cursor-crosshair"
|
className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 cursor-crosshair"
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ const renderWithRouter = (props: Partial<React.ComponentProps<typeof Header>>) =
|
|||||||
return render(
|
return render(
|
||||||
<MemoryRouter>
|
<MemoryRouter>
|
||||||
<Header {...defaultProps} {...props} />
|
<Header {...defaultProps} {...props} />
|
||||||
</MemoryRouter>
|
</MemoryRouter>,
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -19,7 +19,15 @@ export interface HeaderProps {
|
|||||||
onSignOut: () => void;
|
onSignOut: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Header: React.FC<HeaderProps> = ({ isDarkMode, unitSystem, authStatus, userProfile, onOpenProfile, onOpenVoiceAssistant, onSignOut }) => {
|
export const Header: React.FC<HeaderProps> = ({
|
||||||
|
isDarkMode,
|
||||||
|
unitSystem,
|
||||||
|
authStatus,
|
||||||
|
userProfile,
|
||||||
|
onOpenProfile,
|
||||||
|
onOpenVoiceAssistant,
|
||||||
|
onSignOut,
|
||||||
|
}) => {
|
||||||
// The state and handlers for the old AuthModal and SignUpModal have been removed.
|
// The state and handlers for the old AuthModal and SignUpModal have been removed.
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -57,9 +65,13 @@ export const Header: React.FC<HeaderProps> = ({ isDarkMode, unitSystem, authStat
|
|||||||
<UserIcon className="w-5 h-5 text-gray-500 dark:text-gray-400" />
|
<UserIcon className="w-5 h-5 text-gray-500 dark:text-gray-400" />
|
||||||
{authStatus === 'AUTHENTICATED' ? (
|
{authStatus === 'AUTHENTICATED' ? (
|
||||||
// Use the user object from the new auth system
|
// Use the user object from the new auth system
|
||||||
<span className="font-medium text-gray-700 dark:text-gray-300">{userProfile.user.email}</span>
|
<span className="font-medium text-gray-700 dark:text-gray-300">
|
||||||
|
{userProfile.user.email}
|
||||||
|
</span>
|
||||||
) : (
|
) : (
|
||||||
<span className="font-medium text-gray-500 dark:text-gray-400 italic">Guest</span>
|
<span className="font-medium text-gray-500 dark:text-gray-400 italic">
|
||||||
|
Guest
|
||||||
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
@@ -71,7 +83,11 @@ export const Header: React.FC<HeaderProps> = ({ isDarkMode, unitSystem, authStat
|
|||||||
<Cog8ToothIcon className="w-5 h-5" />
|
<Cog8ToothIcon className="w-5 h-5" />
|
||||||
</button>
|
</button>
|
||||||
{userProfile?.role === 'admin' && (
|
{userProfile?.role === 'admin' && (
|
||||||
<Link to="/admin" className="p-1.5 rounded-md hover:bg-gray-100 dark:hover:bg-gray-700/50 text-gray-500 dark:text-gray-400 transition-colors" title="Admin Area">
|
<Link
|
||||||
|
to="/admin"
|
||||||
|
className="p-1.5 rounded-md hover:bg-gray-100 dark:hover:bg-gray-700/50 text-gray-500 dark:text-gray-400 transition-colors"
|
||||||
|
title="Admin Area"
|
||||||
|
>
|
||||||
<ShieldCheckIcon className="w-5 h-5" />
|
<ShieldCheckIcon className="w-5 h-5" />
|
||||||
</Link>
|
</Link>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -26,7 +26,13 @@ vi.mock('lucide-react', () => ({
|
|||||||
|
|
||||||
const mockLeaderboardData: LeaderboardUser[] = [
|
const mockLeaderboardData: LeaderboardUser[] = [
|
||||||
createMockLeaderboardUser({ user_id: 'user-1', full_name: 'Alice', points: 1000, rank: '1' }),
|
createMockLeaderboardUser({ user_id: 'user-1', full_name: 'Alice', points: 1000, rank: '1' }),
|
||||||
createMockLeaderboardUser({ user_id: 'user-2', full_name: 'Bob', avatar_url: 'http://example.com/bob.jpg', points: 950, rank: '2' }),
|
createMockLeaderboardUser({
|
||||||
|
user_id: 'user-2',
|
||||||
|
full_name: 'Bob',
|
||||||
|
avatar_url: 'http://example.com/bob.jpg',
|
||||||
|
points: 950,
|
||||||
|
rank: '2',
|
||||||
|
}),
|
||||||
createMockLeaderboardUser({ user_id: 'user-3', full_name: 'Charlie', points: 900, rank: '3' }),
|
createMockLeaderboardUser({ user_id: 'user-3', full_name: 'Charlie', points: 900, rank: '3' }),
|
||||||
createMockLeaderboardUser({ user_id: 'user-4', full_name: 'Diana', points: 850, rank: '4' }),
|
createMockLeaderboardUser({ user_id: 'user-4', full_name: 'Diana', points: 850, rank: '4' }),
|
||||||
];
|
];
|
||||||
@@ -69,12 +75,16 @@ describe('Leaderboard', () => {
|
|||||||
render(<Leaderboard />);
|
render(<Leaderboard />);
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByText('The leaderboard is currently empty. Be the first to earn points!')).toBeInTheDocument();
|
expect(
|
||||||
|
screen.getByText('The leaderboard is currently empty. Be the first to earn points!'),
|
||||||
|
).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should render the leaderboard with user data on successful fetch', async () => {
|
it('should render the leaderboard with user data on successful fetch', async () => {
|
||||||
mockedApiClient.fetchLeaderboard.mockResolvedValue(new Response(JSON.stringify(mockLeaderboardData)));
|
mockedApiClient.fetchLeaderboard.mockResolvedValue(
|
||||||
|
new Response(JSON.stringify(mockLeaderboardData)),
|
||||||
|
);
|
||||||
render(<Leaderboard />);
|
render(<Leaderboard />);
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
@@ -97,7 +107,9 @@ describe('Leaderboard', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should render the correct rank icons', async () => {
|
it('should render the correct rank icons', async () => {
|
||||||
mockedApiClient.fetchLeaderboard.mockResolvedValue(new Response(JSON.stringify(mockLeaderboardData)));
|
mockedApiClient.fetchLeaderboard.mockResolvedValue(
|
||||||
|
new Response(JSON.stringify(mockLeaderboardData)),
|
||||||
|
);
|
||||||
render(<Leaderboard />);
|
render(<Leaderboard />);
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
@@ -114,7 +126,9 @@ describe('Leaderboard', () => {
|
|||||||
const dataWithMissingNames: LeaderboardUser[] = [
|
const dataWithMissingNames: LeaderboardUser[] = [
|
||||||
createMockLeaderboardUser({ user_id: 'user-anon', full_name: null, points: 500, rank: '5' }),
|
createMockLeaderboardUser({ user_id: 'user-anon', full_name: null, points: 500, rank: '5' }),
|
||||||
];
|
];
|
||||||
mockedApiClient.fetchLeaderboard.mockResolvedValue(new Response(JSON.stringify(dataWithMissingNames)));
|
mockedApiClient.fetchLeaderboard.mockResolvedValue(
|
||||||
|
new Response(JSON.stringify(dataWithMissingNames)),
|
||||||
|
);
|
||||||
render(<Leaderboard />);
|
render(<Leaderboard />);
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
|
|||||||
@@ -51,7 +51,10 @@ export const Leaderboard: React.FC = () => {
|
|||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
return (
|
return (
|
||||||
<div className="bg-red-100 border-l-4 border-red-500 text-red-700 p-4 rounded-md" role="alert">
|
<div
|
||||||
|
className="bg-red-100 border-l-4 border-red-500 text-red-700 p-4 rounded-md"
|
||||||
|
role="alert"
|
||||||
|
>
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
<ShieldAlert className="h-6 w-6 mr-3" />
|
<ShieldAlert className="h-6 w-6 mr-3" />
|
||||||
<p className="font-bold">Error: {error}</p>
|
<p className="font-bold">Error: {error}</p>
|
||||||
@@ -67,21 +70,29 @@ export const Leaderboard: React.FC = () => {
|
|||||||
Top Users
|
Top Users
|
||||||
</h2>
|
</h2>
|
||||||
{leaderboard.length === 0 ? (
|
{leaderboard.length === 0 ? (
|
||||||
<p className="text-gray-500 dark:text-gray-400">The leaderboard is currently empty. Be the first to earn points!</p>
|
<p className="text-gray-500 dark:text-gray-400">
|
||||||
|
The leaderboard is currently empty. Be the first to earn points!
|
||||||
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<ol className="space-y-4">
|
<ol className="space-y-4">
|
||||||
{leaderboard.map((user) => (
|
{leaderboard.map((user) => (
|
||||||
<li key={user.user_id} className="flex items-center space-x-4 p-3 bg-gray-50 dark:bg-gray-700 rounded-lg transition hover:bg-gray-100 dark:hover:bg-gray-600">
|
<li
|
||||||
<div className="shrink-0 w-8 text-center">
|
key={user.user_id}
|
||||||
{getRankIcon(user.rank)}
|
className="flex items-center space-x-4 p-3 bg-gray-50 dark:bg-gray-700 rounded-lg transition hover:bg-gray-100 dark:hover:bg-gray-600"
|
||||||
</div>
|
>
|
||||||
|
<div className="shrink-0 w-8 text-center">{getRankIcon(user.rank)}</div>
|
||||||
<img
|
<img
|
||||||
src={user.avatar_url || `https://api.dicebear.com/8.x/initials/svg?seed=${user.full_name || user.user_id}`}
|
src={
|
||||||
|
user.avatar_url ||
|
||||||
|
`https://api.dicebear.com/8.x/initials/svg?seed=${user.full_name || user.user_id}`
|
||||||
|
}
|
||||||
alt={user.full_name || 'User Avatar'}
|
alt={user.full_name || 'User Avatar'}
|
||||||
className="w-12 h-12 rounded-full object-cover"
|
className="w-12 h-12 rounded-full object-cover"
|
||||||
/>
|
/>
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<p className="font-semibold text-gray-800 dark:text-gray-100">{user.full_name || 'Anonymous User'}</p>
|
<p className="font-semibold text-gray-800 dark:text-gray-100">
|
||||||
|
{user.full_name || 'Anonymous User'}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-lg font-bold text-blue-600 dark:text-blue-400">
|
<div className="text-lg font-bold text-blue-600 dark:text-blue-400">
|
||||||
{user.points} pts
|
{user.points} pts
|
||||||
|
|||||||
@@ -2,8 +2,24 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
|
||||||
export const LoadingSpinner: React.FC = () => (
|
export const LoadingSpinner: React.FC = () => (
|
||||||
<svg className="animate-spin h-full w-full text-current" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
<svg
|
||||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
className="animate-spin h-full w-full text-current"
|
||||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<circle
|
||||||
|
className="opacity-25"
|
||||||
|
cx="12"
|
||||||
|
cy="12"
|
||||||
|
r="10"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="4"
|
||||||
|
></circle>
|
||||||
|
<path
|
||||||
|
className="opacity-75"
|
||||||
|
fill="currentColor"
|
||||||
|
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||||
|
></path>
|
||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
@@ -20,14 +20,14 @@ vi.mock('../config', () => ({
|
|||||||
version: 'test',
|
version: 'test',
|
||||||
commitMessage: 'test',
|
commitMessage: 'test',
|
||||||
commitUrl: 'test',
|
commitUrl: 'test',
|
||||||
}
|
},
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
describe('MapView', () => {
|
describe('MapView', () => {
|
||||||
const defaultProps = {
|
const defaultProps = {
|
||||||
latitude: 40.7128,
|
latitude: 40.7128,
|
||||||
longitude: -74.0060,
|
longitude: -74.006,
|
||||||
};
|
};
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
@@ -41,7 +41,9 @@ describe('MapView', () => {
|
|||||||
describe('when API key is not configured', () => {
|
describe('when API key is not configured', () => {
|
||||||
it('should render a disabled message', () => {
|
it('should render a disabled message', () => {
|
||||||
render(<MapView {...defaultProps} />);
|
render(<MapView {...defaultProps} />);
|
||||||
expect(screen.getByText('Map view is disabled: API key is not configured.')).toBeInTheDocument();
|
expect(
|
||||||
|
screen.getByText('Map view is disabled: API key is not configured.'),
|
||||||
|
).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should not render the iframe', () => {
|
it('should not render the iframe', () => {
|
||||||
|
|||||||
@@ -12,7 +12,9 @@ export const MapView: React.FC<MapViewProps> = ({ latitude, longitude }) => {
|
|||||||
const apiKey = config.google.mapsEmbedApiKey;
|
const apiKey = config.google.mapsEmbedApiKey;
|
||||||
|
|
||||||
if (!apiKey) {
|
if (!apiKey) {
|
||||||
return <div className="text-sm text-red-500">Map view is disabled: API key is not configured.</div>;
|
return (
|
||||||
|
<div className="text-sm text-red-500">Map view is disabled: API key is not configured.</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const mapSrc = `https://www.google.com/maps/embed/v1/view?key=${apiKey}¢er=${latitude},${longitude}&zoom=14`;
|
const mapSrc = `https://www.google.com/maps/embed/v1/view?key=${apiKey}¢er=${latitude},${longitude}&zoom=14`;
|
||||||
|
|||||||
@@ -11,10 +11,15 @@ export const UnitSystemToggle: React.FC<UnitSystemToggleProps> = ({ currentSyste
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
<span className={`text-sm font-medium ${isImperial ? 'text-gray-400 dark:text-gray-500' : 'text-gray-700 dark:text-gray-200'}`}>
|
<span
|
||||||
|
className={`text-sm font-medium ${isImperial ? 'text-gray-400 dark:text-gray-500' : 'text-gray-700 dark:text-gray-200'}`}
|
||||||
|
>
|
||||||
Metric
|
Metric
|
||||||
</span>
|
</span>
|
||||||
<label htmlFor="unit-system-toggle" className="relative inline-flex items-center cursor-pointer">
|
<label
|
||||||
|
htmlFor="unit-system-toggle"
|
||||||
|
className="relative inline-flex items-center cursor-pointer"
|
||||||
|
>
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
id="unit-system-toggle"
|
id="unit-system-toggle"
|
||||||
@@ -24,7 +29,9 @@ export const UnitSystemToggle: React.FC<UnitSystemToggleProps> = ({ currentSyste
|
|||||||
/>
|
/>
|
||||||
<div className="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-2 peer-focus:ring-brand-primary/50 dark:peer-focus:ring-brand-secondary rounded-full peer dark:bg-gray-700 peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-0.5] after:left-0.52px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all dark:border-gray-600 peer-checked:bg-brand-primary"></div>
|
<div className="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-2 peer-focus:ring-brand-primary/50 dark:peer-focus:ring-brand-secondary rounded-full peer dark:bg-gray-700 peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-0.5] after:left-0.52px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all dark:border-gray-600 peer-checked:bg-brand-primary"></div>
|
||||||
</label>
|
</label>
|
||||||
<span className={`text-sm font-medium ${isImperial ? 'text-gray-700 dark:text-gray-200' : 'text-gray-400 dark:text-gray-500'}`}>
|
<span
|
||||||
|
className={`text-sm font-medium ${isImperial ? 'text-gray-700 dark:text-gray-200' : 'text-gray-400 dark:text-gray-500'}`}
|
||||||
|
>
|
||||||
Imperial
|
Imperial
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -22,11 +22,15 @@ describe('UserMenuSkeleton', () => {
|
|||||||
|
|
||||||
it('should render a rectangular placeholder with correct styles', () => {
|
it('should render a rectangular placeholder with correct styles', () => {
|
||||||
const { container } = render(<UserMenuSkeleton />);
|
const { container } = render(<UserMenuSkeleton />);
|
||||||
expect(container.querySelector('.rounded-md')).toHaveClass('h-8 w-24 bg-gray-200 dark:bg-gray-700');
|
expect(container.querySelector('.rounded-md')).toHaveClass(
|
||||||
|
'h-8 w-24 bg-gray-200 dark:bg-gray-700',
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should render a circular placeholder with correct styles', () => {
|
it('should render a circular placeholder with correct styles', () => {
|
||||||
const { container } = render(<UserMenuSkeleton />);
|
const { container } = render(<UserMenuSkeleton />);
|
||||||
expect(container.querySelector('.rounded-full')).toHaveClass('h-10 w-10 bg-gray-200 dark:bg-gray-700');
|
expect(container.querySelector('.rounded-full')).toHaveClass(
|
||||||
|
'h-10 w-10 bg-gray-200 dark:bg-gray-700',
|
||||||
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -10,11 +10,19 @@ export interface WhatsNewModalProps {
|
|||||||
commitMessage: string;
|
commitMessage: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const WhatsNewModal: React.FC<WhatsNewModalProps> = ({ isOpen, onClose, version, commitMessage }) => {
|
export const WhatsNewModal: React.FC<WhatsNewModalProps> = ({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
version,
|
||||||
|
commitMessage,
|
||||||
|
}) => {
|
||||||
if (!isOpen) return null;
|
if (!isOpen) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 bg-black bg-opacity-60 z-50 flex justify-center items-center p-4" onClick={onClose}>
|
<div
|
||||||
|
className="fixed inset-0 bg-black bg-opacity-60 z-50 flex justify-center items-center p-4"
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
<div
|
<div
|
||||||
role="dialog"
|
role="dialog"
|
||||||
aria-modal="true"
|
aria-modal="true"
|
||||||
@@ -28,13 +36,17 @@ export const WhatsNewModal: React.FC<WhatsNewModalProps> = ({ isOpen, onClose, v
|
|||||||
<GiftIcon className="w-6 h-6 text-brand-primary" />
|
<GiftIcon className="w-6 h-6 text-brand-primary" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h2 id="whats-new-title" className="text-xl font-bold text-gray-900 dark:text-white">What's New?</h2>
|
<h2 id="whats-new-title" className="text-xl font-bold text-gray-900 dark:text-white">
|
||||||
|
What's New?
|
||||||
|
</h2>
|
||||||
<p className="text-xs text-gray-500 dark:text-gray-400">Version: {version}</p>
|
<p className="text-xs text-gray-500 dark:text-gray-400">Version: {version}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="bg-gray-100 dark:bg-gray-700 p-4 rounded-lg">
|
<div className="bg-gray-100 dark:bg-gray-700 p-4 rounded-lg">
|
||||||
<p className="text-base font-medium text-gray-800 dark:text-gray-200">{commitMessage}</p>
|
<p className="text-base font-medium text-gray-800 dark:text-gray-200">
|
||||||
|
{commitMessage}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-6 flex justify-end">
|
<div className="mt-6 flex justify-end">
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
// src/components/icons/ArrowPathIcon.tsx
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
|
||||||
export const ArrowPathIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => (
|
export const ArrowPathIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => (
|
||||||
@@ -9,6 +10,10 @@ export const ArrowPathIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) =>
|
|||||||
stroke="currentColor"
|
stroke="currentColor"
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0 3.181 3.183a8.25 8.25 0 0 0 11.667 0l3.181-3.183m-4.991-2.691V5.25a2.25 2.25 0 0 0-2.25-2.25h-6.75a2.25 2.25 0 0 0-2.25 2.25v6.75a2.25 2.25 0 0 0 2.25 2.25h6.75a2.25 2.25 0 0 0 2.25-2.25Z" />
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0 3.181 3.183a8.25 8.25 0 0 0 11.667 0l3.181-3.183m-4.991-2.691V5.25a2.25 2.25 0 0 0-2.25-2.25h-6.75a2.25 2.25 0 0 0-2.25 2.25v6.75a2.25 2.25 0 0 0 2.25 2.25h6.75a2.25 2.25 0 0 0 2.25-2.25Z"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
@@ -1,7 +1,18 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
|
||||||
export const BeakerIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => (
|
export const BeakerIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => (
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" {...props}>
|
<svg
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9.75 3.104v5.714a2.25 2.25 0 0 1-.659 1.591L5 14.5M9.75 3.104c-.251.038-.502.097-.752.172m.752-.172a24.283 24.283 0 0 0-4.085 1.572M9.75 3.104a24.283 24.283 0 0 1 4.085 1.572m-4.085-1.572c.251.038.502.097.752.172m0 0v5.714a2.25 2.25 0 0 0 .659 1.591L19 14.5M9.75 9.104a2.25 2.25 0 0 0-1.5 2.086v.208a2.25 2.25 0 0 0 1.5 2.086m-1.5-4.172c-.251.038-.502.097-.752.172m.752-.172a24.283 24.283 0 0 0-4.085 1.572M5.25 14.5c.251.038.502.097.752.172m-1.5-2.086a2.25 2.25 0 0 1 1.5-2.086m-1.5 2.086v.208a2.25 2.25 0 0 1-1.5 2.086M5.25 14.5a24.283 24.283 0 0 1-4.085-1.572M18.75 14.5c-.251.038-.502.097-.752.172m1.5-2.086a2.25 2.25 0 0 0-1.5-2.086m1.5 2.086v.208a2.25 2.25 0 0 0 1.5 2.086M18.75 14.5a24.283 24.283 0 0 0 4.085-1.572M5.25 14.5L9 18.25m9.75-3.75L15 18.25m-1.5-3.75v3.75m0 0a2.25 2.25 0 0 1-4.5 0m4.5 0a2.25 2.25 0 0 0-4.5 0m4.5 0v3.75m-4.5-3.75v3.75" />
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
strokeWidth={1.5}
|
||||||
|
stroke="currentColor"
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M9.75 3.104v5.714a2.25 2.25 0 0 1-.659 1.591L5 14.5M9.75 3.104c-.251.038-.502.097-.752.172m.752-.172a24.283 24.283 0 0 0-4.085 1.572M9.75 3.104a24.283 24.283 0 0 1 4.085 1.572m-4.085-1.572c.251.038.502.097.752.172m0 0v5.714a2.25 2.25 0 0 0 .659 1.591L19 14.5M9.75 9.104a2.25 2.25 0 0 0-1.5 2.086v.208a2.25 2.25 0 0 0 1.5 2.086m-1.5-4.172c-.251.038-.502.097-.752.172m.752-.172a24.283 24.283 0 0 0-4.085 1.572M5.25 14.5c.251.038.502.097.752.172m-1.5-2.086a2.25 2.25 0 0 1 1.5-2.086m-1.5 2.086v.208a2.25 2.25 0 0 1-1.5 2.086M5.25 14.5a24.283 24.283 0 0 1-4.085-1.572M18.75 14.5c-.251.038-.502.097-.752.172m1.5-2.086a2.25 2.25 0 0 0-1.5-2.086m1.5 2.086v.208a2.25 2.25 0 0 0 1.5 2.086M18.75 14.5a24.283 24.283 0 0 0 4.085-1.572M5.25 14.5L9 18.25m9.75-3.75L15 18.25m-1.5-3.75v3.75m0 0a2.25 2.25 0 0 1-4.5 0m4.5 0a2.25 2.25 0 0 0-4.5 0m4.5 0v3.75m-4.5-3.75v3.75"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
@@ -9,6 +9,10 @@ export const BellAlertIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) =>
|
|||||||
stroke="currentColor"
|
stroke="currentColor"
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M14.857 17.082a23.848 23.848 0 005.454-1.31A8.967 8.967 0 0118 9.75v-.7V9A6 6 0 006 9v.75a8.967 8.967 0 01-2.312 6.022c1.733.64 3.56 1.085 5.455 1.31m5.714 0a24.255 24.255 0 01-5.714 0m5.714 0a3 3 0 11-5.714 0M3.124 7.5A8.969 8.969 0 015.292 3m13.416 0a8.969 8.969 0 012.168 4.5" />
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M14.857 17.082a23.848 23.848 0 005.454-1.31A8.967 8.967 0 0118 9.75v-.7V9A6 6 0 006 9v.75a8.967 8.967 0 01-2.312 6.022c1.733.64 3.56 1.085 5.455 1.31m5.714 0a24.255 24.255 0 01-5.714 0m5.714 0a3 3 0 11-5.714 0M3.124 7.5A8.969 8.969 0 015.292 3m13.416 0a8.969 8.969 0 012.168 4.5"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
@@ -1,9 +1,18 @@
|
|||||||
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
|
||||||
export const BrainIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => (
|
export const BrainIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => (
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" {...props}>
|
<svg
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9.813 15.904L9 18.75l-.813-2.846a4.5 4.5 0 0 0-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 0 0 3.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 0 0 3.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 0 0-3.09 3.09ZM18.25 12l2.846-.813a4.5 4.5 0 0 0 3.09-3.09L21.75 5.25l.813 2.846a4.5 4.5 0 0 0 3.09 3.09L21.75 12l-2.846.813a4.5 4.5 0 0 0-3.09 3.09L15.75 18.75l-.813-2.846a4.5 4.5 0 0 0-3.09-3.09L9 12l2.846-.813a4.5 4.5 0 0 0 3.09-3.09L18.25 12Z" />
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
strokeWidth={1.5}
|
||||||
|
stroke="currentColor"
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M9.813 15.904L9 18.75l-.813-2.846a4.5 4.5 0 0 0-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 0 0 3.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 0 0 3.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 0 0-3.09 3.09ZM18.25 12l2.846-.813a4.5 4.5 0 0 0 3.09-3.09L21.75 5.25l.813 2.846a4.5 4.5 0 0 0 3.09 3.09L21.75 12l-2.846.813a4.5 4.5 0 0 0-3.09 3.09L15.75 18.75l-.813-2.846a4.5 4.5 0 0 0-3.09-3.09L9 12l2.846-.813a4.5 4.5 0 0 0 3.09-3.09L18.25 12Z"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -9,6 +9,10 @@ export const BuildingStorefrontIcon: React.FC<React.SVGProps<SVGSVGElement>> = (
|
|||||||
stroke="currentColor"
|
stroke="currentColor"
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M13.5 21v-7.5A2.25 2.25 0 0115.75 11.25h.5a2.25 2.25 0 012.25 2.25V21M3 16.5v-7.5A2.25 2.25 0 015.25 6.75h13.5A2.25 2.25 0 0121 9v7.5M3 16.5h18M3 16.5v4.5A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75v-4.5M16.5 4.5a3 3 0 11-6 0 3 3 0 016 0z" />
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M13.5 21v-7.5A2.25 2.25 0 0115.75 11.25h.5a2.25 2.25 0 012.25 2.25V21M3 16.5v-7.5A2.25 2.25 0 015.25 6.75h13.5A2.25 2.25 0 0121 9v7.5M3 16.5h18M3 16.5v4.5A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75v-4.5M16.5 4.5a3 3 0 11-6 0 3 3 0 016 0z"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
@@ -9,6 +9,10 @@ export const ChartBarIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) =>
|
|||||||
stroke="currentColor"
|
stroke="currentColor"
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M3 13.125C3 12.504 3.504 12 4.125 12h2.25c.621 0 1.125.504 1.125 1.125v6.75C7.5 20.496 6.996 21 6.375 21h-2.25A1.125 1.125 0 013 19.875v-6.75zM9.75 8.625c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125v11.25c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 01-1.125-1.125V8.625zM16.5 4.125c0-.621.504-1.125 1.125-1.125h2.25C20.496 3 21 3.504 21 4.125v15.75c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 01-1.125-1.125V4.125z" />
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M3 13.125C3 12.504 3.504 12 4.125 12h2.25c.621 0 1.125.504 1.125 1.125v6.75C7.5 20.496 6.996 21 6.375 21h-2.25A1.125 1.125 0 013 19.875v-6.75zM9.75 8.625c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125v11.25c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 01-1.125-1.125V8.625zM16.5 4.125c0-.621.504-1.125 1.125-1.125h2.25C20.496 3 21 3.504 21 4.125v15.75c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 01-1.125-1.125V4.125z"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
@@ -5,7 +5,14 @@ interface CheckCircleIconProps extends React.SVGProps<SVGSVGElement> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const CheckCircleIcon: React.FC<CheckCircleIconProps> = ({ title, ...props }) => (
|
export const CheckCircleIcon: React.FC<CheckCircleIconProps> = ({ title, ...props }) => (
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" {...props}>
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
strokeWidth={1.5}
|
||||||
|
stroke="currentColor"
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
{title && <title>{title}</title>}
|
{title && <title>{title}</title>}
|
||||||
<path
|
<path
|
||||||
strokeLinecap="round"
|
strokeLinecap="round"
|
||||||
|
|||||||
@@ -1,5 +1,14 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
|
||||||
export const CheckIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => (
|
export const CheckIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => (
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" {...props}><path strokeLinecap="round" strokeLinejoin="round" d="m4.5 12.75 6 6 9-13.5" /></svg>
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
strokeWidth={1.5}
|
||||||
|
stroke="currentColor"
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="m4.5 12.75 6 6 9-13.5" />
|
||||||
|
</svg>
|
||||||
);
|
);
|
||||||
@@ -1,5 +1,19 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
|
||||||
export const Cog8ToothIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => (
|
export const Cog8ToothIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => (
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" {...props}><path strokeLinecap="round" strokeLinejoin="round" d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.325.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 0 1 1.37.49l1.296 2.247a1.125 1.125 0 0 1-.26 1.431l-1.003.827c-.293.24-.438.613-.43.992a6.759 6.759 0 0 1 0 1.905c-.008.379.137.752.43.992l1.003.827c.424.35.534.954.26 1.431l-1.296 2.247a1.125 1.125 0 0 1-1.37.49l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.47 6.47 0 0 1-.22.127c-.331.183-.581.495-.644.87l-.213 1.281c-.09.543-.56.94-1.11.94h-2.594c-.55 0-1.02-.398-1.11-.94l-.213-1.281c-.063-.374-.313-.686-.645-.87a6.52 6.52 0 0 1-.22-.127c-.325-.196-.72-.257-1.075-.124l-1.217.456a1.125 1.125 0 0 1-1.37-.49l-1.296-2.247a1.125 1.125 0 0 1 .26-1.431l1.004-.827c.292-.24.437-.613.43-.992a6.759 6.759 0 0 1 0-1.905c.008-.379-.137-.752-.43-.992l-1.004-.827a1.125 1.125 0 0 1-.26-1.431l1.296-2.247a1.125 1.125 0 0 1 1.37-.49l1.217.456c.355.133.75.072 1.076-.124.072-.044.146-.087.22-.127.332-.183.582-.495.644-.87l.213-1.281Z" /><path strokeLinecap="round" strokeLinejoin="round" d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z" /></svg>
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
strokeWidth={1.5}
|
||||||
|
stroke="currentColor"
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.325.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 0 1 1.37.49l1.296 2.247a1.125 1.125 0 0 1-.26 1.431l-1.003.827c-.293.24-.438.613-.43.992a6.759 6.759 0 0 1 0 1.905c-.008.379.137.752.43.992l1.003.827c.424.35.534.954.26 1.431l-1.296 2.247a1.125 1.125 0 0 1-1.37.49l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.47 6.47 0 0 1-.22.127c-.331.183-.581.495-.644.87l-.213 1.281c-.09.543-.56.94-1.11.94h-2.594c-.55 0-1.02-.398-1.11-.94l-.213-1.281c-.063-.374-.313-.686-.645-.87a6.52 6.52 0 0 1-.22-.127c-.325-.196-.72-.257-1.075-.124l-1.217.456a1.125 1.125 0 0 1-1.37-.49l-1.296-2.247a1.125 1.125 0 0 1 .26-1.431l1.004-.827c.292-.24.437-.613.43-.992a6.759 6.759 0 0 1 0-1.905c.008-.379-.137-.752-.43-.992l-1.004-.827a1.125 1.125 0 0 1-.26-1.431l1.296-2.247a1.125 1.125 0 0 1 1.37-.49l1.217.456c.355.133.75.072 1.076-.124.072-.044.146-.087.22-.127.332-.183.582-.495.644-.87l.213-1.281Z"
|
||||||
|
/>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z" />
|
||||||
|
</svg>
|
||||||
);
|
);
|
||||||
@@ -1,7 +1,18 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
|
||||||
export const CogIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => (
|
export const CogIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => (
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" {...props}>
|
<svg
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9.594 3.94c.09-.542.56-1.008 1.11-1.212l.978-.488c.55-.274 1.192.164 1.192.793v.498c0 .266.105.52.293.708l.38.38c.37.37.884.586 1.414.586h.498c.63 0 1.067.641.793 1.192l-.488.978c-.204.449-.67.92-1.212 1.11l-.978.488c-.55.274-1.192-.164-1.192-.793v-.498c0-.266-.105-.52-.293-.708l-.38-.38c-.37-.37-.884-.586-1.414-.586h-.498c-.63 0-1.067-.641-.793-1.192l.488-.978Zm7.406 16.12c.09.542.56 1.008 1.11 1.212l.978.488c.55.274 1.192-.164 1.192-.793v-.498c0-.266-.105-.52-.293-.708l-.38-.38c-.37-.37-.884-.586-1.414-.586h-.498c-.63 0-1.067.641-.793-1.192l.488-.978c.204-.449.67-.92 1.212-1.11l.978-.488c.55-.274 1.192.164 1.192.793v.498c0 .266.105.52.293.708l.38.38c.37.37.884.586 1.414.586h.498c.63 0 1.067-.641.793-1.192l-.488-.978c-.204-.449-.67-.92-1.212-1.11l-.978-.488c-.55-.274-1.192.164-1.192.793v-.498c0-.266-.105-.52-.293-.708l-.38-.38c-.37-.37-.884-.586-1.414-.586h-.498c-.63 0-1.067.641-.793-1.192l.488-.978ZM12 8.25a3.75 3.75 0 1 0 0 7.5 3.75 3.75 0 0 0 0-7.5Z" />
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
strokeWidth={1.5}
|
||||||
|
stroke="currentColor"
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M9.594 3.94c.09-.542.56-1.008 1.11-1.212l.978-.488c.55-.274 1.192.164 1.192.793v.498c0 .266.105.52.293.708l.38.38c.37.37.884.586 1.414.586h.498c.63 0 1.067.641.793 1.192l-.488.978c-.204.449-.67.92-1.212 1.11l-.978.488c-.55.274-1.192-.164-1.192-.793v-.498c0-.266-.105-.52-.293-.708l-.38-.38c-.37-.37-.884-.586-1.414-.586h-.498c-.63 0-1.067-.641-.793-1.192l.488-.978Zm7.406 16.12c.09.542.56 1.008 1.11 1.212l.978.488c.55.274 1.192-.164 1.192-.793v-.498c0-.266-.105-.52-.293-.708l-.38-.38c-.37-.37-.884-.586-1.414-.586h-.498c-.63 0-1.067.641-.793-1.192l.488-.978c.204-.449.67-.92 1.212-1.11l.978-.488c.55-.274 1.192.164 1.192.793v.498c0 .266.105.52.293.708l.38.38c.37.37.884.586 1.414.586h.498c.63 0 1.067-.641.793-1.192l-.488-.978c-.204-.449-.67-.92-1.212-1.11l-.978-.488c-.55-.274-1.192.164-1.192.793v-.498c0-.266-.105-.52-.293-.708l-.38-.38c-.37-.37-.884-.586-1.414-.586h-.498c-.63 0-1.067.641-.793-1.192l.488-.978ZM12 8.25a3.75 3.75 0 1 0 0 7.5 3.75 3.75 0 0 0 0-7.5Z"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
@@ -1,8 +1,18 @@
|
|||||||
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
|
||||||
export const DatabaseIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => (
|
export const DatabaseIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => (
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" {...props}>
|
<svg
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M20.25 6.375c0 2.278-3.694 4.125-8.25 4.125S3.75 8.653 3.75 6.375m16.5 0c0-2.278-3.694-4.125-8.25-4.125S3.75 4.097 3.75 6.375m16.5 0v11.25c0 2.278-3.694 4.125-8.25 4.125s-8.25-1.847-8.25-4.125V6.375m16.5 0v3.75m-16.5-3.75v3.75m16.5 0v3.75C20.25 15.353 16.556 17.25 12 17.25s-8.25-1.897-8.25-4.125V10.125" />
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
strokeWidth={1.5}
|
||||||
|
stroke="currentColor"
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M20.25 6.375c0 2.278-3.694 4.125-8.25 4.125S3.75 8.653 3.75 6.375m16.5 0c0-2.278-3.694-4.125-8.25-4.125S3.75 4.097 3.75 6.375m16.5 0v11.25c0 2.278-3.694 4.125-8.25 4.125s-8.25-1.847-8.25-4.125V6.375m16.5 0v3.75m-16.5-3.75v3.75m16.5 0v3.75C20.25 15.353 16.556 17.25 12 17.25s-8.25-1.897-8.25-4.125V10.125"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -9,6 +9,10 @@ export const DocumentDuplicateIcon: React.FC<React.SVGProps<SVGSVGElement>> = (p
|
|||||||
stroke="currentColor"
|
stroke="currentColor"
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 17.25v3.375c0 .621-.504 1.125-1.125 1.125h-9.75a1.125 1.125 0 01-1.125-1.125V7.875c0-.621.504-1.125 1.125-1.125H6.75a9.06 9.06 0 011.5.124m7.5 10.376h3.375c.621 0 1.125-.504 1.125-1.125V11.25c0-4.46-3.243-8.161-7.5-8.876a9.06 9.06 0 00-1.5-.124H9.375c-.621 0-1.125.504-1.125 1.125v3.5m7.5 10.375H9.375a1.125 1.125 0 01-1.125-1.125v-9.25m12 6.625v-1.875a3.375 3.375 0 00-3.375-3.375h-1.5a1.125 1.125 0 01-1.125-1.125v-1.5a3.375 3.375 0 00-3.375-3.375H9.75" />
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M15.75 17.25v3.375c0 .621-.504 1.125-1.125 1.125h-9.75a1.125 1.125 0 01-1.125-1.125V7.875c0-.621.504-1.125 1.125-1.125H6.75a9.06 9.06 0 011.5.124m7.5 10.376h3.375c.621 0 1.125-.504 1.125-1.125V11.25c0-4.46-3.243-8.161-7.5-8.876a9.06 9.06 0 00-1.5-.124H9.375c-.621 0-1.125.504-1.125 1.125v3.5m7.5 10.375H9.375a1.125 1.125 0 01-1.125-1.125v-9.25m12 6.625v-1.875a3.375 3.375 0 00-3.375-3.375h-1.5a1.125 1.125 0 01-1.125-1.125v-1.5a3.375 3.375 0 00-3.375-3.375H9.75"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
@@ -1,7 +1,18 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
|
||||||
export const DocumentTextIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => (
|
export const DocumentTextIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => (
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" {...props}>
|
<svg
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M19.5 14.25v-2.625a3.375 3.375 0 0 0-3.375-3.375h-1.5A1.125 1.125 0 0 1 13.5 7.125v-1.5a3.375 3.375 0 0 0-3.375-3.375H8.25m0 12.75h7.5m-7.5 3H12M10.5 2.25H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 0 0-9-9Z" />
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
strokeWidth={1.5}
|
||||||
|
stroke="currentColor"
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M19.5 14.25v-2.625a3.375 3.375 0 0 0-3.375-3.375h-1.5A1.125 1.125 0 0 1 13.5 7.125v-1.5a3.375 3.375 0 0 0-3.375-3.375H8.25m0 12.75h7.5m-7.5 3H12M10.5 2.25H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 0 0-9-9Z"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -9,6 +9,10 @@ export const ExclamationTriangleIcon: React.FC<React.SVGProps<SVGSVGElement>> =
|
|||||||
stroke="currentColor"
|
stroke="currentColor"
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z" />
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
@@ -1,8 +1,19 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
|
||||||
export const EyeIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => (
|
export const EyeIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => (
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" {...props}>
|
<svg
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M2.036 12.322a1.012 1.012 0 010-.639l4.443-5.332a1.012 1.012 0 011.536 0l4.443 5.332a1.012 1.012 0 010 .639l-4.443 5.332a1.012 1.012 0 01-1.536 0l-4.443-5.332z" />
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
strokeWidth={1.5}
|
||||||
|
stroke="currentColor"
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M2.036 12.322a1.012 1.012 0 010-.639l4.443-5.332a1.012 1.012 0 011.536 0l4.443 5.332a1.012 1.012 0 010 .639l-4.443 5.332a1.012 1.012 0 01-1.536 0l-4.443-5.332z"
|
||||||
|
/>
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
<path strokeLinecap="round" strokeLinejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
@@ -1,8 +1,19 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
|
||||||
export const EyeSlashIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => (
|
export const EyeSlashIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => (
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" {...props}>
|
<svg
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M3.98 8.223A10.477 10.477 0 001.934 12C3.226 16.338 7.244 19.5 12 19.5c.993 0 1.953-.138 2.863-.395M6.228 6.228A10.45 10.45 0 0112 4.5c4.756 0 8.773 3.162 10.065 7.498a10.523 10.523 0 01-4.293 5.524M2.036 12.322a1.012 1.012 0 010-.639l4.443-5.332a1.012 1.012 0 011.536 0l4.443 5.332a1.012 1.012 0 010 .639l-4.443 5.332a1.012 1.012 0 01-1.536 0l-4.443-5.332z" />
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
strokeWidth={1.5}
|
||||||
|
stroke="currentColor"
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M3.98 8.223A10.477 10.477 0 001.934 12C3.226 16.338 7.244 19.5 12 19.5c.993 0 1.953-.138 2.863-.395M6.228 6.228A10.45 10.45 0 0112 4.5c4.756 0 8.773 3.162 10.065 7.498a10.523 10.523 0 01-4.293 5.524M2.036 12.322a1.012 1.012 0 010-.639l4.443-5.332a1.012 1.012 0 011.536 0l4.443 5.332a1.012 1.012 0 010 .639l-4.443 5.332a1.012 1.012 0 01-1.536 0l-4.443-5.332z"
|
||||||
|
/>
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
<path strokeLinecap="round" strokeLinejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
@@ -1,7 +1,18 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
|
||||||
export const GiftIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => (
|
export const GiftIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => (
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" {...props}>
|
<svg
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M21 11.25v8.25a1.5 1.5 0 01-1.5 1.5H7.5a1.5 1.5 0 01-1.5-1.5v-8.25M12 1.5v10.5m0 0l3-3m-3 3l-3-3m3 3V3.75M21 11.25H3" />
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
strokeWidth={1.5}
|
||||||
|
stroke="currentColor"
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M21 11.25v8.25a1.5 1.5 0 01-1.5 1.5H7.5a1.5 1.5 0 01-1.5-1.5v-8.25M12 1.5v10.5m0 0l3-3m-3 3l-3-3m3 3V3.75M21 11.25H3"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
@@ -2,6 +2,10 @@ import React from 'react';
|
|||||||
|
|
||||||
export const GithubIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => (
|
export const GithubIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => (
|
||||||
<svg viewBox="0 0 24 24" fill="currentColor" {...props}>
|
<svg viewBox="0 0 24 24" fill="currentColor" {...props}>
|
||||||
<path fillRule="evenodd" d="M12 2C6.477 2 2 6.477 2 12c0 4.418 2.865 8.168 6.839 9.492.5.092.682-.217.682-.482 0-.237-.009-.868-.014-1.703-2.782.605-3.369-1.343-3.369-1.343-.454-1.158-1.11-1.466-1.11-1.466-.908-.62.069-.608.069-.608 1.003.07 1.531 1.032 1.531 1.032.892 1.53 2.341 1.088 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.113-4.555-4.951 0-1.093.39-1.988 1.031-2.688-.103-.253-.446-1.272.098-2.65 0 0 .84-.27 2.75 1.026A9.564 9.564 0 0112 6.844c.85.004 1.705.115 2.504.337 1.909-1.296 2.747-1.027 2.747-1.027.546 1.379.203 2.398.1 2.651.64.7 1.03 1.595 1.03 2.688 0 3.848-2.338 4.695-4.566 4.942.359.308.678.92.678 1.855 0 1.338-.012 2.419-.012 2.747 0 .268.18.58.688.482A10.001 10.001 0 0022 12c0-5.523-4.477-10-10-10z" clipRule="evenodd" />
|
<path
|
||||||
|
fillRule="evenodd"
|
||||||
|
d="M12 2C6.477 2 2 6.477 2 12c0 4.418 2.865 8.168 6.839 9.492.5.092.682-.217.682-.482 0-.237-.009-.868-.014-1.703-2.782.605-3.369-1.343-3.369-1.343-.454-1.158-1.11-1.466-1.11-1.466-.908-.62.069-.608.069-.608 1.003.07 1.531 1.032 1.531 1.032.892 1.53 2.341 1.088 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.113-4.555-4.951 0-1.093.39-1.988 1.031-2.688-.103-.253-.446-1.272.098-2.65 0 0 .84-.27 2.75 1.026A9.564 9.564 0 0112 6.844c.85.004 1.705.115 2.504.337 1.909-1.296 2.747-1.027 2.747-1.027.546 1.379.203 2.398.1 2.651.64.7 1.03 1.595 1.03 2.688 0 3.848-2.338 4.695-4.566 4.942.359.308.678.92.678 1.855 0 1.338-.012 2.419-.012 2.747 0 .268.18.58.688.482A10.001 10.001 0 0022 12c0-5.523-4.477-10-10-10z"
|
||||||
|
clipRule="evenodd"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -2,9 +2,21 @@ import React from 'react';
|
|||||||
|
|
||||||
export const GoogleIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => (
|
export const GoogleIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => (
|
||||||
<svg viewBox="0 0 48 48" {...props}>
|
<svg viewBox="0 0 48 48" {...props}>
|
||||||
<path fill="#FFC107" d="M43.611 20.083H42V20H24v8h11.303c-1.649 4.657-6.08 8-11.303 8c-6.627 0-12-5.373-12-12s5.373-12 12-12c3.059 0 5.842 1.154 7.961 3.039l5.657-5.657C34.046 6.053 29.268 4 24 4C12.955 4 4 12.955 4 24s8.955 20 20 20s20-8.955 20-20c0-1.341-.138-2.65-.389-3.917z"></path>
|
<path
|
||||||
<path fill="#FF3D00" d="M6.306 14.691l6.571 4.819C14.655 15.108 18.961 12 24 12c3.059 0 5.842 1.154 7.961 3.039l5.657-5.657C34.046 6.053 29.268 4 24 4C16.318 4 9.656 8.337 6.306 14.691z"></path>
|
fill="#FFC107"
|
||||||
<path fill="#4CAF50" d="M24 44c5.166 0 9.86-1.977 13.409-5.192l-6.19-5.238A11.91 11.91 0 0 1 24 36c-5.202 0-9.619-3.317-11.283-7.946l-6.522 5.025C9.505 39.556 16.227 44 24 44z"></path>
|
d="M43.611 20.083H42V20H24v8h11.303c-1.649 4.657-6.08 8-11.303 8c-6.627 0-12-5.373-12-12s5.373-12 12-12c3.059 0 5.842 1.154 7.961 3.039l5.657-5.657C34.046 6.053 29.268 4 24 4C12.955 4 4 12.955 4 24s8.955 20 20 20s20-8.955 20-20c0-1.341-.138-2.65-.389-3.917z"
|
||||||
<path fill="#1976D2" d="M43.611 20.083H42V20H24v8h11.303c-.792 2.237-2.231 4.166-4.087 5.571l6.19 5.238C42.011 35.638 44 30.138 44 24c0-1.341-.138-2.65-.389-3.917z"></path>
|
></path>
|
||||||
|
<path
|
||||||
|
fill="#FF3D00"
|
||||||
|
d="M6.306 14.691l6.571 4.819C14.655 15.108 18.961 12 24 12c3.059 0 5.842 1.154 7.961 3.039l5.657-5.657C34.046 6.053 29.268 4 24 4C16.318 4 9.656 8.337 6.306 14.691z"
|
||||||
|
></path>
|
||||||
|
<path
|
||||||
|
fill="#4CAF50"
|
||||||
|
d="M24 44c5.166 0 9.86-1.977 13.409-5.192l-6.19-5.238A11.91 11.91 0 0 1 24 36c-5.202 0-9.619-3.317-11.283-7.946l-6.522 5.025C9.505 39.556 16.227 44 24 44z"
|
||||||
|
></path>
|
||||||
|
<path
|
||||||
|
fill="#1976D2"
|
||||||
|
d="M43.611 20.083H42V20H24v8h11.303c-.792 2.237-2.231 4.166-4.087 5.571l6.19 5.238C42.011 35.638 44 30.138 44 24c0-1.341-.138-2.65-.389-3.917z"
|
||||||
|
></path>
|
||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,7 +1,18 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
|
||||||
export const InformationCircleIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => (
|
export const InformationCircleIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => (
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" {...props}>
|
<svg
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="m11.25 11.25.041-.02a.75.75 0 0 1 1.063.852l-.708 2.836a.75.75 0 0 0 1.063.853l.041-.021M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Zm-9-3.75h.008v.008H12V8.25Z" />
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
strokeWidth={1.5}
|
||||||
|
stroke="currentColor"
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="m11.25 11.25.041-.02a.75.75 0 0 1 1.063.852l-.708 2.836a.75.75 0 0 0 1.063.853l.041-.021M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Zm-9-3.75h.008v.008H12V8.25Z"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
@@ -1,9 +1,18 @@
|
|||||||
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
|
||||||
export const LightbulbIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => (
|
export const LightbulbIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => (
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" {...props}>
|
<svg
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 18v-5.25m0 0a6.01 6.01 0 0 0 1.5-1.555c1.424-1.423 2.1-3.393 1.83-5.252A7.488 7.488 0 0 0 12 3a7.488 7.488 0 0 0-5.33 2.143c-.27 2.03.506 3.99 1.83 5.252a6.01 6.01 0 0 0 1.5 1.555Zm-1.5 3.75a.75.75 0 0 0 3 0" />
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
strokeWidth={1.5}
|
||||||
|
stroke="currentColor"
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M12 18v-5.25m0 0a6.01 6.01 0 0 0 1.5-1.555c1.424-1.423 2.1-3.393 1.83-5.252A7.488 7.488 0 0 0 12 3a7.488 7.488 0 0 0-5.33 2.143c-.27 2.03.506 3.99 1.83 5.252a6.01 6.01 0 0 0 1.5 1.555Zm-1.5 3.75a.75.75 0 0 0 3 0"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -1,7 +1,18 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
|
||||||
export const ListBulletIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => (
|
export const ListBulletIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => (
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" {...props}>
|
<svg
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M8.25 6.75h12M8.25 12h12m-12 5.25h12M3.75 6.75h.007v.008H3.75V6.75Zm.375 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0ZM3.75 12h.007v.008H3.75V12Zm.375 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Zm-.375 5.25h.007v.008H3.75v-.008Zm.375 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Z" />
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
strokeWidth={1.5}
|
||||||
|
stroke="currentColor"
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M8.25 6.75h12M8.25 12h12m-12 5.25h12M3.75 6.75h.007v.008H3.75V6.75Zm.375 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0ZM3.75 12h.007v.008H3.75V12Zm.375 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Zm-.375 5.25h.007v.008H3.75v-.008Zm.375 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Z"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
@@ -1,8 +1,19 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
|
||||||
export const MapPinIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => (
|
export const MapPinIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => (
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" {...props}>
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
strokeWidth={1.5}
|
||||||
|
stroke="currentColor"
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15 10.5a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z" />
|
<path strokeLinecap="round" strokeLinejoin="round" d="M15 10.5a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z" />
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M19.5 10.5c0 7.142-7.5 11.25-7.5 11.25S4.5 17.642 4.5 10.5a7.5 7.5 0 1 1 15 0Z" />
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M19.5 10.5c0 7.142-7.5 11.25-7.5 11.25S4.5 17.642 4.5 10.5a7.5 7.5 0 1 1 15 0Z"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
@@ -1,7 +1,18 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
|
||||||
export const MicrophoneIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => (
|
export const MicrophoneIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => (
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" {...props}>
|
<svg
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 18.75a6 6 0 0 0 6-6v-1.5m-6 7.5a6 6 0 0 1-6-6v-1.5m12 0v-1.5a6 6 0 0 0-6-6v0a6 6 0 0 0-6 6v1.5m6 7.5v3.75m-3.75-3.75h7.5" />
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
strokeWidth={1.5}
|
||||||
|
stroke="currentColor"
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M12 18.75a6 6 0 0 0 6-6v-1.5m-6 7.5a6 6 0 0 1-6-6v-1.5m12 0v-1.5a6 6 0 0 0-6-6v0a6 6 0 0 0-6 6v1.5m6 7.5v3.75m-3.75-3.75h7.5"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
@@ -1,8 +1,18 @@
|
|||||||
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
|
||||||
export const MoonIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => (
|
export const MoonIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => (
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" {...props}>
|
<svg
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M21.752 15.002A9.72 9.72 0 0 1 18 15.75c-5.385 0-9.75-4.365-9.75-9.75 0-1.33.266-2.597.748-3.752A9.753 9.753 0 0 0 3 11.25c0 5.385 4.365 9.75 9.75 9.75 2.572 0 4.921-.994 6.752-2.648Z" />
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
strokeWidth={1.5}
|
||||||
|
stroke="currentColor"
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M21.752 15.002A9.72 9.72 0 0 1 18 15.75c-5.385 0-9.75-4.365-9.75-9.75 0-1.33.266-2.597.748-3.752A9.753 9.753 0 0 0 3 11.25c0 5.385 4.365 9.75 9.75 9.75 2.572 0 4.921-.994 6.752-2.648Z"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,8 +1,18 @@
|
|||||||
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
|
||||||
export const PdfIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => (
|
export const PdfIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => (
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" {...props}>
|
<svg
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M19.5 14.25v-2.625a3.375 3.375 0 0 0-3.375-3.375h-1.5A1.125 1.125 0 0 1 13.5 7.125v-1.5a3.375 3.375 0 0 0-3.375-3.375H8.25m2.25 0H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 0 0-9-9Z" />
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
strokeWidth={1.5}
|
||||||
|
stroke="currentColor"
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M19.5 14.25v-2.625a3.375 3.375 0 0 0-3.375-3.375h-1.5A1.125 1.125 0 0 1 13.5 7.125v-1.5a3.375 3.375 0 0 0-3.375-3.375H8.25m2.25 0H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 0 0-9-9Z"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -9,6 +9,10 @@ export const PencilIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => (
|
|||||||
stroke="currentColor"
|
stroke="currentColor"
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L10.582 16.07a4.5 4.5 0 01-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 011.13-1.897l8.932-8.931zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0115.75 21H5.25A2.25 2.25 0 013 18.75V8.25A2.25 2.25 0 015.25 6H10" />
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L10.582 16.07a4.5 4.5 0 01-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 011.13-1.897l8.932-8.931zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0115.75 21H5.25A2.25 2.25 0 013 18.75V8.25A2.25 2.25 0 015.25 6H10"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
@@ -1,7 +1,18 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
|
||||||
export const PhotoIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => (
|
export const PhotoIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => (
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" {...props}>
|
<svg
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="m2.25 15.75 5.159-5.159a2.25 2.25 0 0 1 3.182 0l5.159 5.159m-1.5-1.5 1.409-1.409a2.25 2.25 0 0 1 3.182 0l2.909 2.909m-18 3.75h16.5a1.5 1.5 0 0 0 1.5-1.5V6a1.5 1.5 0 0 0-1.5-1.5H3.75A1.5 1.5 0 0 0 2.25 6v12a1.5 1.5 0 0 0 1.5 1.5Zm10.5-11.25h.008v.008h-.008V8.25Zm.375 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Z" />
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
strokeWidth={1.5}
|
||||||
|
stroke="currentColor"
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="m2.25 15.75 5.159-5.159a2.25 2.25 0 0 1 3.182 0l5.159 5.159m-1.5-1.5 1.409-1.409a2.25 2.25 0 0 1 3.182 0l2.909 2.909m-18 3.75h16.5a1.5 1.5 0 0 0 1.5-1.5V6a1.5 1.5 0 0 0-1.5-1.5H3.75A1.5 1.5 0 0 0 2.25 6v12a1.5 1.5 0 0 0 1.5 1.5Zm10.5-11.25h.008v.008h-.008V8.25Zm.375 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Z"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
@@ -1,8 +1,23 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
|
||||||
export const PlugIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => (
|
export const PlugIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => (
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" {...props}>
|
<svg
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M8.25 3v1.5M4.5 8.25H3m18 0h-1.5M4.5 12H3m18 0h-1.5m-15 3.75H3m18 0h-1.5M8.25 19.5V21M12 3v1.5m0 15V21m3.75-18v1.5M19.5 8.25h-1.5m-15 3.75h1.5m15 0h1.5m-15 3.75h1.5m15 0h1.5" />
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 6.75A5.25 5.25 0 0 0 6.75 12a5.25 5.25 0 0 0 5.25 5.25a5.25 5.25 0 0 0 5.25-5.25A5.25 5.25 0 0 0 12 6.75Z" />
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
strokeWidth={1.5}
|
||||||
|
stroke="currentColor"
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M8.25 3v1.5M4.5 8.25H3m18 0h-1.5M4.5 12H3m18 0h-1.5m-15 3.75H3m18 0h-1.5M8.25 19.5V21M12 3v1.5m0 15V21m3.75-18v1.5M19.5 8.25h-1.5m-15 3.75h1.5m15 0h1.5m-15 3.75h1.5m15 0h1.5"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M12 6.75A5.25 5.25 0 0 0 6.75 12a5.25 5.25 0 0 0 5.25 5.25a5.25 5.25 0 0 0 5.25-5.25A5.25 5.25 0 0 0 12 6.75Z"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,7 +1,18 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
|
||||||
export const PlusCircleIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => (
|
export const PlusCircleIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => (
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" {...props}>
|
<svg
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 9v6m3-3H9m12 0a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z" />
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
strokeWidth={1.5}
|
||||||
|
stroke="currentColor"
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M12 9v6m3-3H9m12 0a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
@@ -1,7 +1,18 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
|
||||||
export const QuestionMarkCircleIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => (
|
export const QuestionMarkCircleIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => (
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" {...props}>
|
<svg
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9.879 7.519c1.171-1.025 3.071-1.025 4.242 0 1.172 1.025 1.172 2.687 0 3.712-.203.179-.43.326-.67.442-.745.361-1.45.999-1.45 1.827v.75M21 12a9 9 0 11-18 0 9 9 0 0118 0zm-9 5.25h.008v.008H12v-.008z" />
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
strokeWidth={1.5}
|
||||||
|
stroke="currentColor"
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M9.879 7.519c1.171-1.025 3.071-1.025 4.242 0 1.172 1.025 1.172 2.687 0 3.712-.203.179-.43.326-.67.442-.745.361-1.45.999-1.45 1.827v.75M21 12a9 9 0 11-18 0 9 9 0 0118 0zm-9 5.25h.008v.008H12v-.008z"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
@@ -2,5 +2,18 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
|
||||||
export const RefreshCwIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => (
|
export const RefreshCwIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => (
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" {...props}><path strokeLinecap="round" strokeLinejoin="round" d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0 3.181 3.183a8.25 8.25 0 0 0 11.667 0l3.181-3.183m-4.991-2.696v4.992h-4.992m0 0-3.181-3.183a8.25 8.25 0 0 1 11.667 0l3.181 3.183" /></svg>
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
strokeWidth={1.5}
|
||||||
|
stroke="currentColor"
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0 3.181 3.183a8.25 8.25 0 0 0 11.667 0l3.181-3.183m-4.991-2.696v4.992h-4.992m0 0-3.181-3.183a8.25 8.25 0 0 1 11.667 0l3.181 3.183"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
);
|
);
|
||||||
@@ -2,7 +2,18 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
|
||||||
export const ScaleIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => (
|
export const ScaleIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => (
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" {...props}>
|
<svg
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 3v17.25m0 0c-1.472 0-2.882.265-4.185.75M12 20.25c1.472 0 2.882.265 4.185.75M18.75 4.97A48.416 48.416 0 0 0 12 4.5c-2.291 0-4.545.16-6.75.47m13.5 0c-1.472 0-2.882.265-4.185.75M5.25 4.97C3.947 5.235 2.538 5.5 1.25 5.5m1.5 14.55A48.348 48.348 0 0 0 12 20.25a48.348 48.348 0 0 0 9.25-1.2M1.25 5.5a48.348 48.348 0 0 1 9.25-1.2m0 0c1.472 0 2.882.265 4.185.75" />
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
strokeWidth={1.5}
|
||||||
|
stroke="currentColor"
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M12 3v17.25m0 0c-1.472 0-2.882.265-4.185.75M12 20.25c1.472 0 2.882.265 4.185.75M18.75 4.97A48.416 48.416 0 0 0 12 4.5c-2.291 0-4.545.16-6.75.47m13.5 0c-1.472 0-2.882.265-4.185.75M5.25 4.97C3.947 5.235 2.538 5.5 1.25 5.5m1.5 14.55A48.348 48.348 0 0 0 12 20.25a48.348 48.348 0 0 0 9.25-1.2M1.25 5.5a48.348 48.348 0 0 1 9.25-1.2m0 0c1.472 0 2.882.265 4.185.75"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
@@ -2,5 +2,18 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
|
||||||
export const ScanIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => (
|
export const ScanIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => (
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" {...props}><path strokeLinecap="round" strokeLinejoin="round" d="M3.75 4.5v15h15V4.5h-15Zm-1.5-1.5h18a1.5 1.5 0 0 1 1.5 1.5v18a1.5 1.5 0 0 1-1.5 1.5h-18a1.5 1.5 0 0 1-1.5-1.5v-18A1.5 1.5 0 0 1 2.25 3ZM12 8.25v7.5" /></svg>
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
strokeWidth={1.5}
|
||||||
|
stroke="currentColor"
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M3.75 4.5v15h15V4.5h-15Zm-1.5-1.5h18a1.5 1.5 0 0 1 1.5 1.5v18a1.5 1.5 0 0 1-1.5 1.5h-18a1.5 1.5 0 0 1-1.5-1.5v-18A1.5 1.5 0 0 1 2.25 3ZM12 8.25v7.5"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
);
|
);
|
||||||
@@ -2,5 +2,18 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
|
||||||
export const ScissorsIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => (
|
export const ScissorsIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => (
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" {...props}><path strokeLinecap="round" strokeLinejoin="round" d="M7.5 7.5h-.75A2.25 2.25 0 0 0 4.5 9.75v.75c0 .414.336.75.75.75h.75m0-1.5h.375c.621 0 1.125.504 1.125 1.125v1.5c0 .621-.504 1.125-1.125 1.125h-.375m0-1.5H7.5m9-6h.75a2.25 2.25 0 0 1 2.25 2.25v.75c0 .414-.336.75-.75.75h-.75m0-1.5h-.375c-.621 0-1.125.504-1.125 1.125v1.5c0 .621.504 1.125 1.125 1.125h.375m0-1.5H16.5m-9 3.75h1.5a.75.75 0 0 1 .75.75v.75c0 .414-.336.75-.75.75h-1.5a.75.75 0 0 1-.75-.75v-.75a.75.75 0 0 1 .75-.75Zm9 3.75h-1.5a.75.75 0 0 0-.75.75v.75c0 .414.336.75.75.75h1.5a.75.75 0 0 0 .75-.75v-.75a.75.75 0 0 0-.75-.75Z" /></svg>
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
strokeWidth={1.5}
|
||||||
|
stroke="currentColor"
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M7.5 7.5h-.75A2.25 2.25 0 0 0 4.5 9.75v.75c0 .414.336.75.75.75h.75m0-1.5h.375c.621 0 1.125.504 1.125 1.125v1.5c0 .621-.504 1.125-1.125 1.125h-.375m0-1.5H7.5m9-6h.75a2.25 2.25 0 0 1 2.25 2.25v.75c0 .414-.336.75-.75.75h-.75m0-1.5h-.375c-.621 0-1.125.504-1.125 1.125v1.5c0 .621.504 1.125 1.125 1.125h.375m0-1.5H16.5m-9 3.75h1.5a.75.75 0 0 1 .75.75v.75c0 .414-.336.75-.75.75h-1.5a.75.75 0 0 1-.75-.75v-.75a.75.75 0 0 1 .75-.75Zm9 3.75h-1.5a.75.75 0 0 0-.75.75v.75c0 .414.336.75.75.75h1.5a.75.75 0 0 0 .75-.75v-.75a.75.75 0 0 0-.75-.75Z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
);
|
);
|
||||||
@@ -1,9 +1,18 @@
|
|||||||
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
|
||||||
export const SearchIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => (
|
export const SearchIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => (
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" {...props}>
|
<svg
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="m21 21-5.197-5.197m0 0A7.5 7.5 0 1 0 5.196 5.196a7.5 7.5 0 0 0 10.607 10.607Z" />
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
strokeWidth={1.5}
|
||||||
|
stroke="currentColor"
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="m21 21-5.197-5.197m0 0A7.5 7.5 0 1 0 5.196 5.196a7.5 7.5 0 0 0 10.607 10.607Z"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -1,7 +1,18 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
|
||||||
export const ServerIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => (
|
export const ServerIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => (
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" {...props}>
|
<svg
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M21.75 17.25v-.228a4.5 4.5 0 0 0-.12-1.03l-2.268-9.64a3.375 3.375 0 0 0-3.285-2.602H7.923a3.375 3.375 0 0 0-3.285 2.602l-2.268 9.64a4.5 4.5 0 0 0-.12 1.03v.228m15.459 0a2.25 2.25 0 0 1-2.25 2.25h-10.5a2.25 2.25 0 0 1-2.25-2.25m15 0c0-1.657-1.343-3-3-3s-3 1.343-3 3m0 0c0 1.657 1.343 3 3 3s3-1.343 3-3m-9.75 0c0-1.657-1.343-3-3-3s-3 1.343-3 3m0 0c0 1.657 1.343 3 3 3s3-1.343 3-3m9.75 0c0-1.657-1.343-3-3-3s-3 1.343-3 3m0 0c0 1.657 1.343 3 3 3s3-1.343 3-3" />
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
strokeWidth={1.5}
|
||||||
|
stroke="currentColor"
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M21.75 17.25v-.228a4.5 4.5 0 0 0-.12-1.03l-2.268-9.64a3.375 3.375 0 0 0-3.285-2.602H7.923a3.375 3.375 0 0 0-3.285 2.602l-2.268 9.64a4.5 4.5 0 0 0-.12 1.03v.228m15.459 0a2.25 2.25 0 0 1-2.25 2.25h-10.5a2.25 2.25 0 0 1-2.25-2.25m15 0c0-1.657-1.343-3-3-3s-3 1.343-3 3m0 0c0 1.657 1.343 3 3 3s3-1.343 3-3m-9.75 0c0-1.657-1.343-3-3-3s-3 1.343-3 3m0 0c0 1.657 1.343 3 3 3s3-1.343 3-3m9.75 0c0-1.657-1.343-3-3-3s-3 1.343-3 3m0 0c0 1.657 1.343 3 3 3s3-1.343 3-3"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
@@ -1,7 +1,18 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
|
||||||
export const ShieldCheckIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => (
|
export const ShieldCheckIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => (
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" {...props}>
|
<svg
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12.75 11.25 15 15 9.75m-3-7.036A11.959 11.959 0 0 1 3.598 6 11.99 11.99 0 0 0 3 9.749c0 5.592 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.31-.21-2.571-.598-3.751h-.152c-3.196 0-6.1-1.248-8.25-3.286Zm0 13.036h.008v.008h-.008v-.008Z" />
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
strokeWidth={1.5}
|
||||||
|
stroke="currentColor"
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M9 12.75 11.25 15 15 9.75m-3-7.036A11.959 11.959 0 0 1 3.598 6 11.99 11.99 0 0 0 3 9.749c0 5.592 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.31-.21-2.571-.598-3.751h-.152c-3.196 0-6.1-1.248-8.25-3.286Zm0 13.036h.008v.008h-.008v-.008Z"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,5 +1,18 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
|
||||||
export const ShieldExclamationIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => (
|
export const ShieldExclamationIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => (
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" {...props}><path strokeLinecap="round" strokeLinejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126ZM12 15.75h.007v.008H12v-.008Z" /></svg>
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
strokeWidth={1.5}
|
||||||
|
stroke="currentColor"
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126ZM12 15.75h.007v.008H12v-.008Z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
);
|
);
|
||||||
@@ -1,9 +1,18 @@
|
|||||||
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
|
||||||
export const ShoppingCartIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => (
|
export const ShoppingCartIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => (
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" {...props}>
|
<svg
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M2.25 3h1.386c.51 0 .955.343 1.087.835l.383 1.437M7.5 14.25a3 3 0 0 0-3 3h15.75m-12.75-3h11.218c.51 0 .962-.343 1.087-.835l1.823-6.831a.75.75 0 0 0-.54-1.022l-13.5-4.5a.75.75 0 0 0-.916.606Z" />
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
strokeWidth={1.5}
|
||||||
|
stroke="currentColor"
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M2.25 3h1.386c.51 0 .955.343 1.087.835l.383 1.437M7.5 14.25a3 3 0 0 0-3 3h15.75m-12.75-3h11.218c.51 0 .962-.343 1.087-.835l1.823-6.831a.75.75 0 0 0-.54-1.022l-13.5-4.5a.75.75 0 0 0-.916.606Z"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -1,7 +1,18 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
|
||||||
export const SortAscIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => (
|
export const SortAscIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => (
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" {...props}>
|
<svg
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M3 4.5h14.25M3 9h9.75M3 13.5h5.25m5.25-.75L17.25 9m0 0L21 12.75M17.25 9v12" />
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
strokeWidth={1.5}
|
||||||
|
stroke="currentColor"
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M3 4.5h14.25M3 9h9.75M3 13.5h5.25m5.25-.75L17.25 9m0 0L21 12.75M17.25 9v12"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
@@ -1,7 +1,18 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
|
||||||
export const SortDescIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => (
|
export const SortDescIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => (
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" {...props}>
|
<svg
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M3 4.5h14.25M3 9h9.75M3 13.5h5.25m5.25 6.75L17.25 15m0 0L21 18m-3.75-3v12" />
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
strokeWidth={1.5}
|
||||||
|
stroke="currentColor"
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M3 4.5h14.25M3 9h9.75M3 13.5h5.25m5.25 6.75L17.25 15m0 0L21 18m-3.75-3v12"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
@@ -1,7 +1,18 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
|
||||||
export const SparklesIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => (
|
export const SparklesIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => (
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" {...props}>
|
<svg
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9.813 15.904 9 18.75l-.813-2.846a4.5 4.5 0 0 0-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 0 0 3.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 0 0 3.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 0 0-3.09 3.09ZM18.25 12l2.846-.813a4.5 4.5 0 0 0 3.09-3.09L21.75 5.25l.813 2.846a4.5 4.5 0 0 0 3.09 3.09L21.75 12l-2.846.813a4.5 4.5 0 0 0-3.09 3.09L15.75 18.75l-.813-2.846a4.5 4.5 0 0 0-3.09-3.09L9 12l2.846-.813a4.5 4.5 0 0 0 3.09-3.09L18.25 12Z" />
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
strokeWidth={1.5}
|
||||||
|
stroke="currentColor"
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M9.813 15.904 9 18.75l-.813-2.846a4.5 4.5 0 0 0-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 0 0 3.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 0 0 3.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 0 0-3.09 3.09ZM18.25 12l2.846-.813a4.5 4.5 0 0 0 3.09-3.09L21.75 5.25l.813 2.846a4.5 4.5 0 0 0 3.09 3.09L21.75 12l-2.846.813a4.5 4.5 0 0 0-3.09 3.09L15.75 18.75l-.813-2.846a4.5 4.5 0 0 0-3.09-3.09L9 12l2.846-.813a4.5 4.5 0 0 0 3.09-3.09L18.25 12Z"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,7 +1,18 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
|
||||||
export const SpeakerWaveIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => (
|
export const SpeakerWaveIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => (
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" {...props}>
|
<svg
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M19.114 5.636a9 9 0 0 1 0 12.728M16.463 8.288a5.25 5.25 0 0 1 0 7.424M6.75 8.25l4.72-4.72a.75.75 0 0 1 1.28.53v15.88a.75.75 0 0 1-1.28.53l-4.72-4.72H4.51c-.88 0-1.704-.507-1.938-1.354A9.009 9.009 0 0 1 2.25 12c0-.83.112-1.633.322-2.396C2.806 8.756 3.63 8.25 4.51 8.25H6.75Z" />
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
strokeWidth={1.5}
|
||||||
|
stroke="currentColor"
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M19.114 5.636a9 9 0 0 1 0 12.728M16.463 8.288a5.25 5.25 0 0 1 0 7.424M6.75 8.25l4.72-4.72a.75.75 0 0 1 1.28.53v15.88a.75.75 0 0 1-1.28.53l-4.72-4.72H4.51c-.88 0-1.704-.507-1.938-1.354A9.009 9.009 0 0 1 2.25 12c0-.83.112-1.633.322-2.396C2.806 8.756 3.63 8.25 4.51 8.25H6.75Z"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
@@ -1,8 +1,18 @@
|
|||||||
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
|
||||||
export const SunIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => (
|
export const SunIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => (
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" {...props}>
|
<svg
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 3v2.25m6.364.386-1.591 1.591M21 12h-2.25m-.386 6.364-1.591-1.591M12 18.75V21m-4.773-4.227-1.591 1.591M5.25 12H3m4.227-4.773L5.636 5.636M15.75 12a3.75 3.75 0 1 1-7.5 0 3.75 3.75 0 0 1 7.5 0Z" />
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
strokeWidth={1.5}
|
||||||
|
stroke="currentColor"
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M12 3v2.25m6.364.386-1.591 1.591M21 12h-2.25m-.386 6.364-1.591-1.591M12 18.75V21m-4.773-4.227-1.591 1.591M5.25 12H3m4.227-4.773L5.636 5.636M15.75 12a3.75 3.75 0 1 1-7.5 0 3.75 3.75 0 0 1 7.5 0Z"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,9 +1,19 @@
|
|||||||
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
|
||||||
export const TagIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => (
|
export const TagIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => (
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" {...props}>
|
<svg
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9.568 3H5.25A2.25 2.25 0 0 0 3 5.25v4.318c0 .597.237 1.17.659 1.591l9.581 9.581c.699.699 1.78.872 2.607.33a18.095 18.095 0 0 0 5.223-5.223c.542-.827.369-1.908-.33-2.607L11.16 3.66A2.25 2.25 0 0 0 9.568 3Z" />
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
strokeWidth={1.5}
|
||||||
|
stroke="currentColor"
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M9.568 3H5.25A2.25 2.25 0 0 0 3 5.25v4.318c0 .597.237 1.17.659 1.591l9.581 9.581c.699.699 1.78.872 2.607.33a18.095 18.095 0 0 0 5.223-5.223c.542-.827.369-1.908-.33-2.607L11.16 3.66A2.25 2.25 0 0 0 9.568 3Z"
|
||||||
|
/>
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6 6h.008v.008H6V6Z" />
|
<path strokeLinecap="round" strokeLinejoin="round" d="M6 6h.008v.008H6V6Z" />
|
||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user