Compare commits
28 Commits
matthew-sc
...
0.5.8
| Author | SHA1 | Date | |
|---|---|---|---|
| 3118f71320 | |||
| d12f23aa81 | |||
| e9a8a3c1e7 | |||
| 1e0efe6011 | |||
| 16557f1e4b | |||
| c4a54967bc | |||
| 20ade415dc | |||
| 6689520270 | |||
| 3fd6c2b80d | |||
| a5c5c2b9dd | |||
| cf33a39fbc | |||
| 8629cefa13 | |||
| 5e851e442f | |||
| 4a43bc9c6c | |||
| 60de8cee62 | |||
|
|
bb2a4ab76e | ||
|
|
048dded278 | ||
| e240c2940a | |||
| 54dca9e745 | |||
| 9f0fed0a60 | |||
| 0d152adbf2 | |||
| cead308800 | |||
| 676a301331 | |||
| d6db81cc36 | |||
|
|
f2ddcd2541 | ||
| fb81f7b96e | |||
| a23416ead1 | |||
| 530c7c1a13 |
@@ -1,6 +0,0 @@
|
||||
---
|
||||
description:
|
||||
globs:
|
||||
alwaysApply: true
|
||||
---
|
||||
use the system date function to understand the proper date and time for all interactions.
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -55,3 +55,4 @@ build_logs/
|
||||
icons
|
||||
|
||||
|
||||
android/app/src/main/res/
|
||||
38
BUILDING.md
38
BUILDING.md
@@ -41,6 +41,7 @@ Install dependencies:
|
||||
1. Run the production build:
|
||||
|
||||
```bash
|
||||
rm -rf dist
|
||||
npm run build:web
|
||||
```
|
||||
|
||||
@@ -64,6 +65,8 @@ Install dependencies:
|
||||
|
||||
* Commit everything (since the commit hash is used the app).
|
||||
|
||||
* Run a build to make sure package-lock version is updated, linting works, etc: `npm install && npm run build`
|
||||
|
||||
* Put the commit hash in the changelog (which will help you remember to bump the version later).
|
||||
|
||||
* Tag with the new version, [online](https://gitea.anomalistdesign.com/trent_larson/crowd-funder-for-time-pwa/releases) or `git tag 0.3.55 && git push origin 0.3.55`.
|
||||
@@ -71,7 +74,7 @@ Install dependencies:
|
||||
* For test, build the app (because test server is not yet set up to build):
|
||||
|
||||
```bash
|
||||
TIME_SAFARI_APP_TITLE="TimeSafari_Test" VITE_APP_SERVER=https://test.timesafari.app VITE_BVC_MEETUPS_PROJECT_CLAIM_ID=https://endorser.ch/entity/01HWE8FWHQ1YGP7GFZYYPS272F VITE_DEFAULT_ENDORSER_API_SERVER=https://test-api.endorser.ch VITE_DEFAULT_IMAGE_API_SERVER=https://test-image-api.timesafari.app VITE_DEFAULT_PARTNER_API_SERVER=https://test-partner-api.endorser.ch VITE_DEFAULT_PUSH_SERVER=https://test.timesafari.app VITE_PASSKEYS_ENABLED=true npm run build
|
||||
TIME_SAFARI_APP_TITLE="TimeSafari_Test" VITE_APP_SERVER=https://test.timesafari.app VITE_BVC_MEETUPS_PROJECT_CLAIM_ID=https://endorser.ch/entity/01HWE8FWHQ1YGP7GFZYYPS272F VITE_DEFAULT_ENDORSER_API_SERVER=https://test-api.endorser.ch VITE_DEFAULT_IMAGE_API_SERVER=https://test-image-api.timesafari.app VITE_DEFAULT_PARTNER_API_SERVER=https://test-partner-api.endorser.ch VITE_DEFAULT_PUSH_SERVER=https://test.timesafari.app VITE_PASSKEYS_ENABLED=true npm run build:web
|
||||
```
|
||||
|
||||
... and transfer to the test server:
|
||||
@@ -321,11 +324,11 @@ Prerequisites: macOS with Xcode installed
|
||||
|
||||
#### Each Release
|
||||
|
||||
0. First time (or if XCode dependencies change):
|
||||
0. First time (or if dependencies change):
|
||||
|
||||
- `pkgx +rubygems.org sh`
|
||||
|
||||
- ... and you may have to fix these, especially with pkgx
|
||||
- ... and you may have to fix these, especially with pkgx:
|
||||
|
||||
```bash
|
||||
gem_path=$(which gem)
|
||||
@@ -334,23 +337,12 @@ Prerequisites: macOS with Xcode installed
|
||||
export GEM_PATH=$shortened_path
|
||||
```
|
||||
|
||||
```bash
|
||||
cd ios/App
|
||||
pod install
|
||||
```
|
||||
|
||||
1. Build the web assets:
|
||||
1. Build the web assets & update ios:
|
||||
|
||||
```bash
|
||||
rm -rf dist
|
||||
npm run build:web
|
||||
npm run build:capacitor
|
||||
```
|
||||
|
||||
|
||||
2. Update iOS project with latest build:
|
||||
|
||||
```bash
|
||||
npx cap sync ios
|
||||
```
|
||||
|
||||
@@ -367,15 +359,14 @@ Prerequisites: macOS with Xcode installed
|
||||
npx capacitor-assets generate --ios
|
||||
```
|
||||
|
||||
4. Bump the version to match Android:
|
||||
4. Bump the version to match Android & package.json:
|
||||
|
||||
```
|
||||
cd ios/App
|
||||
xcrun agvtool new-version 25
|
||||
xcrun agvtool new-version 34
|
||||
# Unfortunately this edits Info.plist directly.
|
||||
#xcrun agvtool new-marketing-version 0.4.5
|
||||
cat App.xcodeproj/project.pbxproj | sed "s/MARKETING_VERSION = .*;/MARKETING_VERSION = 0.5.1;/g" > temp
|
||||
mv temp App.xcodeproj/project.pbxproj
|
||||
cat App.xcodeproj/project.pbxproj | sed "s/MARKETING_VERSION = .*;/MARKETING_VERSION = 0.5.8;/g" > temp && mv temp App.xcodeproj/project.pbxproj
|
||||
cd -
|
||||
```
|
||||
|
||||
@@ -427,7 +418,7 @@ Prerequisites: Android Studio with Java SDK installed
|
||||
npx capacitor-assets generate --android
|
||||
```
|
||||
|
||||
4. Bump version to match iOS: android/app/build.gradle
|
||||
4. Bump version to match iOS & package.json: android/app/build.gradle
|
||||
|
||||
5. Open the project in Android Studio:
|
||||
|
||||
@@ -444,7 +435,6 @@ Prerequisites: Android Studio with Java SDK installed
|
||||
./gradlew clean
|
||||
./gradlew build -Dlint.baselines.continue=true
|
||||
cd -
|
||||
npx cap run android
|
||||
```
|
||||
|
||||
... or, to create the `aab` file, `bundle` instead of `build`:
|
||||
@@ -478,7 +468,7 @@ At play.google.com/console:
|
||||
- Note that if you add testers, you have to go to "Publishing Overview" and send those changes or your (closed) testers won't see it.
|
||||
|
||||
|
||||
## First-time Android Configuration for deep links
|
||||
## Android Configuration for deep links
|
||||
|
||||
You must add the following intent filter to the `android/app/src/main/AndroidManifest.xml` file:
|
||||
|
||||
@@ -489,4 +479,6 @@ You must add the following intent filter to the `android/app/src/main/AndroidMan
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
<data android:scheme="timesafari" />
|
||||
</intent-filter>
|
||||
```
|
||||
```
|
||||
|
||||
... though when we tried that most recently it failed to 'build' the APK with: http(s) scheme and host attribute are missing, but are required for Android App Links [AppLinkUrlError]
|
||||
|
||||
@@ -1,538 +0,0 @@
|
||||
# CEFPython Implementation Survey for TimeSafari
|
||||
|
||||
**Author:** Matthew Raymer
|
||||
**Date:** December 2025
|
||||
**Project:** TimeSafari Cross-Platform Desktop Implementation
|
||||
|
||||
## Executive Summary
|
||||
|
||||
This survey evaluates implementing CEFPython as an additional desktop platform for TimeSafari, with full integration into the existing migration system used by Capacitor and native web platforms.
|
||||
|
||||
### Key Findings
|
||||
|
||||
**Feasibility:** ✅ **Highly Feasible** - CEFPython can integrate seamlessly with TimeSafari's existing architecture
|
||||
|
||||
**Migration System Compatibility:** ✅ **Full Compatibility** - Can use the exact same `migration.ts` system as Capacitor and web
|
||||
|
||||
**Performance:** ✅ **Excellent** - Native Python backend with Chromium rendering engine
|
||||
|
||||
**Security:** ✅ **Strong** - Chromium's security model with Python backend isolation
|
||||
|
||||
---
|
||||
|
||||
## 1. Architecture Overview
|
||||
|
||||
### 1.1 Current Platform Architecture
|
||||
|
||||
TimeSafari uses a sophisticated cross-platform architecture with shared codebase and platform-specific implementations:
|
||||
|
||||
```typescript
|
||||
src/
|
||||
├── main.common.ts # Shared initialization
|
||||
├── main.web.ts # Web/PWA entry point
|
||||
├── main.capacitor.ts # Mobile entry point
|
||||
├── main.electron.ts # Electron entry point
|
||||
├── main.pywebview.ts # PyWebView entry point
|
||||
├── main.cefpython.ts # NEW: CEFPython entry point
|
||||
├── services/
|
||||
│ ├── PlatformService.ts # Platform abstraction interface
|
||||
│ ├── PlatformServiceFactory.ts
|
||||
│ └── platforms/
|
||||
│ ├── WebPlatformService.ts
|
||||
│ ├── CapacitorPlatformService.ts
|
||||
│ ├── ElectronPlatformService.ts
|
||||
│ ├── PyWebViewPlatformService.ts
|
||||
│ └── CEFPythonPlatformService.ts # NEW
|
||||
└── cefpython/ # NEW: CEFPython backend
|
||||
├── main.py
|
||||
├── handlers/
|
||||
│ ├── database.py # SQLite with migration support
|
||||
│ ├── crypto.py # Cryptographic operations
|
||||
│ └── api.py # API server integration
|
||||
└── bridge/
|
||||
└── javascript_bridge.py # JS-Python communication
|
||||
```
|
||||
|
||||
### 1.2 Migration System Integration
|
||||
|
||||
**Key Insight:** CEFPython can use the exact same migration system as Capacitor and web platforms:
|
||||
|
||||
```typescript
|
||||
// src/main.cefpython.ts - CEFPython entry point
|
||||
import { initializeApp } from "./main.common";
|
||||
import { runMigrations } from "./db-sql/migration";
|
||||
import { CEFPythonPlatformService } from "./services/platforms/CEFPythonPlatformService";
|
||||
|
||||
const app = initializeApp();
|
||||
|
||||
// Initialize CEFPython platform service
|
||||
const platformService = new CEFPythonPlatformService();
|
||||
|
||||
// Run migrations using the same system as Capacitor
|
||||
async function initializeDatabase() {
|
||||
const sqlExec = (sql: string) => platformService.dbExecute(sql);
|
||||
const sqlQuery = (sql: string) => platformService.dbQuery(sql);
|
||||
const extractMigrationNames = (result: any) => {
|
||||
const names = result.values?.map((row: any) => row.name) || [];
|
||||
return new Set(names);
|
||||
};
|
||||
|
||||
await runMigrations(sqlExec, sqlQuery, extractMigrationNames);
|
||||
}
|
||||
|
||||
// Initialize database before mounting app
|
||||
initializeDatabase().then(() => {
|
||||
app.mount("#app");
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. Python Backend Implementation
|
||||
|
||||
### 2.1 Database Handler with Migration Support
|
||||
|
||||
```python
|
||||
# src/cefpython/handlers/database.py
|
||||
import sqlite3
|
||||
from pathlib import Path
|
||||
from typing import List, Dict, Any
|
||||
|
||||
class DatabaseHandler:
|
||||
def __init__(self):
|
||||
self.db_path = self._get_db_path()
|
||||
self.connection = sqlite3.connect(str(self.db_path))
|
||||
self.connection.row_factory = sqlite3.Row
|
||||
|
||||
# Configure for better performance
|
||||
self.connection.execute("PRAGMA journal_mode=WAL;")
|
||||
self.connection.execute("PRAGMA synchronous=NORMAL;")
|
||||
|
||||
def query(self, sql: str, params: List[Any] = None) -> Dict[str, Any]:
|
||||
"""Execute SQL query and return results in Capacitor-compatible format"""
|
||||
cursor = self.connection.cursor()
|
||||
|
||||
if params:
|
||||
cursor.execute(sql, params)
|
||||
else:
|
||||
cursor.execute(sql)
|
||||
|
||||
if sql.strip().upper().startswith('SELECT'):
|
||||
columns = [description[0] for description in cursor.description]
|
||||
rows = []
|
||||
for row in cursor.fetchall():
|
||||
rows.append(dict(zip(columns, row)))
|
||||
return {'values': rows} # Match Capacitor format
|
||||
else:
|
||||
self.connection.commit()
|
||||
return {'affected_rows': cursor.rowcount}
|
||||
|
||||
def execute(self, sql: string, params: List[Any] = None) -> Dict[str, Any]:
|
||||
"""Execute SQL statement (for INSERT, UPDATE, DELETE, CREATE)"""
|
||||
cursor = self.connection.cursor()
|
||||
|
||||
if params:
|
||||
cursor.execute(sql, params)
|
||||
else:
|
||||
cursor.execute(sql)
|
||||
|
||||
self.connection.commit()
|
||||
|
||||
return {
|
||||
'changes': {
|
||||
'changes': cursor.rowcount,
|
||||
'lastId': cursor.lastrowid
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2.2 Platform Service Implementation
|
||||
|
||||
```typescript
|
||||
// src/services/platforms/CEFPythonPlatformService.ts
|
||||
import { PlatformService } from '../PlatformService';
|
||||
import { runMigrations } from '@/db-sql/migration';
|
||||
|
||||
export class CEFPythonPlatformService implements PlatformService {
|
||||
private bridge: any;
|
||||
|
||||
constructor() {
|
||||
this.bridge = (window as any).cefBridge;
|
||||
if (!this.bridge) {
|
||||
throw new Error('CEFPython bridge not available');
|
||||
}
|
||||
}
|
||||
|
||||
// Database operations using the same interface as Capacitor
|
||||
async dbQuery(sql: string, params?: any[]): Promise<any> {
|
||||
const result = await this.bridge.call('database', 'query', sql, params || []);
|
||||
return result;
|
||||
}
|
||||
|
||||
async dbExecute(sql: string, params?: any[]): Promise<any> {
|
||||
const result = await this.bridge.call('database', 'execute', sql, params || []);
|
||||
return result;
|
||||
}
|
||||
|
||||
// Migration system integration
|
||||
async runMigrations(): Promise<void> {
|
||||
const sqlExec: (sql: string) => Promise<any> = this.dbExecute.bind(this);
|
||||
const sqlQuery: (sql: string) => Promise<any> = this.dbQuery.bind(this);
|
||||
const extractMigrationNames: (result: any) => Set<string> = (result) => {
|
||||
const names = result.values?.map((row: any) => row.name) || [];
|
||||
return new Set(names);
|
||||
};
|
||||
|
||||
await runMigrations(sqlExec, sqlQuery, extractMigrationNames);
|
||||
}
|
||||
|
||||
// Platform detection
|
||||
isCEFPython(): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
getCapabilities(): PlatformCapabilities {
|
||||
return {
|
||||
hasCamera: true,
|
||||
hasFileSystem: true,
|
||||
hasNotifications: true,
|
||||
hasSQLite: true,
|
||||
hasCrypto: true
|
||||
};
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Migration System Compatibility
|
||||
|
||||
### 3.1 Key Advantage
|
||||
|
||||
**CEFPython can use the exact same migration system as Capacitor:**
|
||||
|
||||
```typescript
|
||||
// Both Capacitor and CEFPython use the same migration.ts
|
||||
import { runMigrations } from '@/db-sql/migration';
|
||||
|
||||
// Capacitor implementation
|
||||
const sqlExec: (sql: string) => Promise<capSQLiteChanges> = this.db.execute.bind(this.db);
|
||||
const sqlQuery: (sql: string) => Promise<DBSQLiteValues> = this.db.query.bind(this.db);
|
||||
|
||||
// CEFPython implementation
|
||||
const sqlExec: (sql: string) => Promise<any> = this.dbExecute.bind(this);
|
||||
const sqlQuery: (sql: string) => Promise<any> = this.dbQuery.bind(this);
|
||||
|
||||
// Both use the same migration runner
|
||||
await runMigrations(sqlExec, sqlQuery, extractMigrationNames);
|
||||
```
|
||||
|
||||
### 3.2 Database Format Compatibility
|
||||
|
||||
The Python database handler returns data in the same format as Capacitor:
|
||||
|
||||
```python
|
||||
# Python returns Capacitor-compatible format
|
||||
def query(self, sql: str, params: List[Any] = None) -> Dict[str, Any]:
|
||||
# ... execute query ...
|
||||
return {
|
||||
'values': [
|
||||
{'name': '001_initial', 'executed_at': '2025-01-01'},
|
||||
{'name': '002_add_contacts', 'executed_at': '2025-01-02'}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
This matches the Capacitor format:
|
||||
```typescript
|
||||
// Capacitor returns same format
|
||||
const result = await this.db.query("SELECT name FROM migrations");
|
||||
// result = { values: [{ name: '001_initial' }, { name: '002_add_contacts' }] }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Build Configuration
|
||||
|
||||
### 4.1 Vite Configuration
|
||||
|
||||
```typescript
|
||||
// vite.config.cefpython.mts
|
||||
import { defineConfig } from 'vite';
|
||||
import { createBuildConfig } from './vite.config.common.mts';
|
||||
|
||||
export default defineConfig({
|
||||
...createBuildConfig('cefpython'),
|
||||
|
||||
define: {
|
||||
'process.env.VITE_PLATFORM': JSON.stringify('cefpython'),
|
||||
'process.env.VITE_PWA_ENABLED': JSON.stringify(false),
|
||||
__IS_MOBILE__: JSON.stringify(false),
|
||||
__USE_QR_READER__: JSON.stringify(true)
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
### 4.2 Package.json Scripts
|
||||
|
||||
```json
|
||||
{
|
||||
"scripts": {
|
||||
"build:cefpython": "vite build --config vite.config.cefpython.mts",
|
||||
"dev:cefpython": "concurrently \"npm run dev:web\" \"python src/cefpython/main.py --dev\"",
|
||||
"test:cefpython": "python -m pytest tests/cefpython/"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 4.3 Python Requirements
|
||||
|
||||
```txt
|
||||
# requirements-cefpython.txt
|
||||
cefpython3>=66.1
|
||||
cryptography>=3.4.0
|
||||
requests>=2.25.0
|
||||
pyinstaller>=4.0
|
||||
pytest>=6.0.0
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Platform Service Factory Integration
|
||||
|
||||
### 5.1 Updated Factory
|
||||
|
||||
```typescript
|
||||
// src/services/PlatformServiceFactory.ts
|
||||
import { CEFPythonPlatformService } from './platforms/CEFPythonPlatformService';
|
||||
|
||||
export function createPlatformService(platform: string): PlatformService {
|
||||
switch (platform) {
|
||||
case 'web':
|
||||
return new WebPlatformService();
|
||||
case 'capacitor':
|
||||
return new CapacitorPlatformService();
|
||||
case 'electron':
|
||||
return new ElectronPlatformService();
|
||||
case 'pywebview':
|
||||
return new PyWebViewPlatformService();
|
||||
case 'cefpython':
|
||||
return new CEFPythonPlatformService(); // NEW
|
||||
default:
|
||||
throw new Error(`Unsupported platform: ${platform}`);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. Performance and Security Analysis
|
||||
|
||||
### 6.1 Performance Comparison
|
||||
|
||||
| Metric | Electron | PyWebView | CEFPython | Notes |
|
||||
|--------|----------|-----------|-----------|-------|
|
||||
| **Memory Usage** | 150-200MB | 80-120MB | 100-150MB | CEFPython more efficient than Electron |
|
||||
| **Startup Time** | 3-5s | 2-3s | 2-4s | Similar to PyWebView |
|
||||
| **Database Performance** | Good | Good | Excellent | Native SQLite |
|
||||
| **Crypto Performance** | Good | Good | Excellent | Native Python crypto |
|
||||
| **Bundle Size** | 120-150MB | 50-80MB | 80-120MB | Smaller than Electron |
|
||||
|
||||
### 6.2 Security Features
|
||||
|
||||
```python
|
||||
# src/cefpython/utils/security.py
|
||||
class SecurityManager:
|
||||
def __init__(self):
|
||||
self.blocked_domains = set(['malicious-site.com'])
|
||||
self.allowed_schemes = {'https', 'http', 'file'}
|
||||
|
||||
def validate_network_access(self, url: str) -> bool:
|
||||
"""Validate if network access is allowed"""
|
||||
from urllib.parse import urlparse
|
||||
parsed = urlparse(url)
|
||||
|
||||
# Check blocked domains
|
||||
if parsed.hostname in self.blocked_domains:
|
||||
return False
|
||||
|
||||
# Allow HTTPS only for external domains
|
||||
if parsed.scheme != 'https' and parsed.hostname != 'localhost':
|
||||
return False
|
||||
|
||||
return True
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. Migration Strategy
|
||||
|
||||
### 7.1 Phase 1: Foundation (Week 1-2)
|
||||
|
||||
**Objectives:**
|
||||
- Set up CEFPython development environment
|
||||
- Create basic application structure
|
||||
- Implement database handler with migration support
|
||||
- Establish JavaScript-Python bridge
|
||||
|
||||
**Deliverables:**
|
||||
- [ ] Basic CEFPython application that loads TimeSafari web app
|
||||
- [ ] Database handler with SQLite integration
|
||||
- [ ] Migration system integration
|
||||
- [ ] JavaScript bridge for communication
|
||||
|
||||
### 7.2 Phase 2: Platform Integration (Week 3-4)
|
||||
|
||||
**Objectives:**
|
||||
- Implement CEFPython platform service
|
||||
- Integrate with existing migration system
|
||||
- Test database operations with real data
|
||||
- Validate migration compatibility
|
||||
|
||||
**Deliverables:**
|
||||
- [ ] CEFPython platform service implementation
|
||||
- [ ] Migration system integration
|
||||
- [ ] Database compatibility testing
|
||||
- [ ] Performance benchmarking
|
||||
|
||||
### 7.3 Phase 3: Feature Integration (Week 5-6)
|
||||
|
||||
**Objectives:**
|
||||
- Integrate with existing platform features
|
||||
- Implement API server integration
|
||||
- Add security features
|
||||
- Test with real user workflows
|
||||
|
||||
**Deliverables:**
|
||||
- [ ] Full feature compatibility
|
||||
- [ ] API integration
|
||||
- [ ] Security implementation
|
||||
- [ ] User workflow testing
|
||||
|
||||
### 7.4 Phase 4: Polish and Distribution (Week 7-8)
|
||||
|
||||
**Objectives:**
|
||||
- Optimize performance
|
||||
- Add build and distribution scripts
|
||||
- Create documentation
|
||||
- Prepare for release
|
||||
|
||||
**Deliverables:**
|
||||
- [ ] Performance optimization
|
||||
- [ ] Build automation
|
||||
- [ ] Documentation
|
||||
- [ ] Release-ready application
|
||||
|
||||
---
|
||||
|
||||
## 8. Risk Assessment
|
||||
|
||||
### 8.1 Technical Risks
|
||||
|
||||
| Risk | Probability | Impact | Mitigation |
|
||||
|------|-------------|--------|------------|
|
||||
| **CEFPython compatibility issues** | Medium | High | Use stable CEFPython version, test thoroughly |
|
||||
| **Migration system integration** | Low | High | Follow existing patterns, extensive testing |
|
||||
| **Performance issues** | Low | Medium | Benchmark early, optimize as needed |
|
||||
| **Security vulnerabilities** | Low | High | Implement security manager, regular audits |
|
||||
|
||||
### 8.2 Development Risks
|
||||
|
||||
| Risk | Probability | Impact | Mitigation |
|
||||
|------|-------------|--------|------------|
|
||||
| **Python/CEF knowledge gap** | Medium | Medium | Training, documentation, pair programming |
|
||||
| **Integration complexity** | Medium | Medium | Incremental development, extensive testing |
|
||||
| **Build system complexity** | Low | Medium | Automated build scripts, CI/CD |
|
||||
|
||||
---
|
||||
|
||||
## 9. Success Metrics
|
||||
|
||||
### 9.1 Technical Metrics
|
||||
|
||||
- [ ] **Migration Compatibility:** 100% compatibility with existing migration system
|
||||
- [ ] **Performance:** < 150MB memory usage, < 4s startup time
|
||||
- [ ] **Security:** Pass security audit, no critical vulnerabilities
|
||||
- [ ] **Reliability:** 99%+ uptime, < 1% error rate
|
||||
|
||||
### 9.2 Development Metrics
|
||||
|
||||
- [ ] **Code Quality:** 90%+ test coverage, < 5% code duplication
|
||||
- [ ] **Documentation:** Complete API documentation, user guides
|
||||
- [ ] **Build Automation:** Automated builds, CI/CD pipeline
|
||||
- [ ] **Release Readiness:** Production-ready application
|
||||
|
||||
---
|
||||
|
||||
## 10. Conclusion
|
||||
|
||||
### 10.1 Recommendation
|
||||
|
||||
**✅ PROCEED WITH IMPLEMENTATION**
|
||||
|
||||
CEFPython provides an excellent opportunity to add a robust desktop platform to TimeSafari with:
|
||||
|
||||
1. **Full Migration System Compatibility:** Can use the exact same migration system as Capacitor and web
|
||||
2. **Native Performance:** Python backend with Chromium rendering
|
||||
3. **Security:** Chromium's security model with Python backend isolation
|
||||
4. **Development Efficiency:** Follows established patterns and architecture
|
||||
|
||||
### 10.2 Implementation Priority
|
||||
|
||||
**High Priority:**
|
||||
- Database handler with migration support
|
||||
- JavaScript-Python bridge
|
||||
- Platform service integration
|
||||
- Basic application structure
|
||||
|
||||
**Medium Priority:**
|
||||
- Crypto handler integration
|
||||
- API server integration
|
||||
- Security features
|
||||
- Performance optimization
|
||||
|
||||
**Low Priority:**
|
||||
- Advanced features
|
||||
- Build automation
|
||||
- Documentation
|
||||
- Distribution packaging
|
||||
|
||||
### 10.3 Timeline
|
||||
|
||||
**Total Duration:** 8 weeks (2 months)
|
||||
**Team Size:** 1-2 developers
|
||||
**Risk Level:** Medium
|
||||
**Confidence:** 85%
|
||||
|
||||
The implementation leverages TimeSafari's existing architecture and migration system, making it a natural addition to the platform ecosystem while providing users with a high-performance desktop option.
|
||||
|
||||
---
|
||||
|
||||
## 11. Next Steps
|
||||
|
||||
1. **Immediate Actions:**
|
||||
- Set up development environment
|
||||
- Create basic CEFPython application structure
|
||||
- Implement database handler with migration support
|
||||
|
||||
2. **Week 1-2:**
|
||||
- Complete foundation implementation
|
||||
- Test migration system integration
|
||||
- Validate database operations
|
||||
|
||||
3. **Week 3-4:**
|
||||
- Implement platform service
|
||||
- Integrate with existing features
|
||||
- Begin performance testing
|
||||
|
||||
4. **Week 5-6:**
|
||||
- Complete feature integration
|
||||
- Security implementation
|
||||
- User workflow testing
|
||||
|
||||
5. **Week 7-8:**
|
||||
- Performance optimization
|
||||
- Build automation
|
||||
- Documentation and release preparation
|
||||
|
||||
This implementation will provide TimeSafari users with a robust, secure, and high-performance desktop application that seamlessly integrates with the existing ecosystem.
|
||||
@@ -6,6 +6,13 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
|
||||
## [0.5.8]
|
||||
### Added
|
||||
- /deep-link/ path for URLs that are shared with people
|
||||
### Changed
|
||||
- External links now go to /deep-link/...
|
||||
- Feed visuals now have arrow imagery from giver to receiver
|
||||
|
||||
|
||||
## [0.4.7]
|
||||
### Fixed
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,469 +0,0 @@
|
||||
# GiftedDialog Component Decomposition Plan
|
||||
|
||||
## Overview
|
||||
|
||||
This document outlines a comprehensive plan to refactor the GiftedDialog component by breaking it into smaller, more manageable sub-components. This approach will improve maintainability, testability, and reusability while preparing the codebase for future Pinia integration.
|
||||
|
||||
## Current State Analysis
|
||||
|
||||
The GiftedDialog component (1060 lines) is a complex Vue component that handles:
|
||||
|
||||
- **Two-step wizard UI**: Entity selection → Gift details
|
||||
- **Multiple entity types**: Person/Project as giver/recipient
|
||||
- **Complex conditional rendering**: Based on context and entity types
|
||||
- **Form validation and submission**: Gift recording with API integration
|
||||
- **State management**: UI flow, entity selection, form data
|
||||
|
||||
### Key Challenges
|
||||
|
||||
1. **Large single file**: Difficult to navigate and maintain
|
||||
2. **Mixed concerns**: UI logic, business logic, and API calls in one place
|
||||
3. **Complex state**: Multiple interconnected reactive properties
|
||||
4. **Testing difficulty**: Hard to test individual features in isolation
|
||||
5. **Reusability**: Components like entity grids could be reused elsewhere
|
||||
|
||||
## Decomposition Strategy
|
||||
|
||||
### Phase 1: Extract Display Components (✅ COMPLETED)
|
||||
|
||||
These components handle pure presentation with minimal business logic:
|
||||
|
||||
#### 1. PersonCard.vue ✅
|
||||
|
||||
- **Purpose**: Display individual person entities with selection capability
|
||||
- **Features**:
|
||||
- Person avatar using EntityIcon
|
||||
- Selection states (selectable, conflicted, disabled)
|
||||
- Time icon overlay for contacts
|
||||
- Click event handling
|
||||
- **Props**: `person`, `selectable`, `conflicted`, `showTimeIcon`
|
||||
- **Emits**: `person-selected`
|
||||
|
||||
#### 2. ProjectCard.vue ✅
|
||||
|
||||
- **Purpose**: Display individual project entities with selection capability
|
||||
- **Features**:
|
||||
- Project icon using ProjectIcon
|
||||
- Project name and issuer information
|
||||
- Click event handling
|
||||
- **Props**: `project`, `activeDid`, `allMyDids`, `allContacts`
|
||||
- **Emits**: `project-selected`
|
||||
|
||||
#### 3. EntitySummaryButton.vue ✅
|
||||
|
||||
- **Purpose**: Display selected entity with edit capability in step 2
|
||||
- **Features**:
|
||||
- Entity avatar (person or project)
|
||||
- Entity name and role label
|
||||
- Editable vs locked states
|
||||
- Edit button functionality
|
||||
- **Props**: `entity`, `entityType`, `label`, `editable`
|
||||
- **Emits**: `edit-requested`
|
||||
|
||||
#### 4. AmountInput.vue ✅
|
||||
|
||||
- **Purpose**: Specialized numeric input with increment/decrement controls
|
||||
- **Features**:
|
||||
- Increment/decrement buttons with validation
|
||||
- Configurable min/max values and step size
|
||||
- Input validation and formatting
|
||||
- v-model compatibility
|
||||
- **Props**: `value`, `min`, `max`, `step`, `inputId`
|
||||
- **Emits**: `update:value`
|
||||
|
||||
### Phase 2: Extract Layout Components (✅ COMPLETED)
|
||||
|
||||
These components handle layout and entity organization:
|
||||
|
||||
#### 5. EntityGrid.vue ✅
|
||||
|
||||
- **Purpose**: Unified grid layout for displaying people or projects
|
||||
- **Features**:
|
||||
- Responsive grid layout for people/projects
|
||||
- Special entity integration (You, Unnamed)
|
||||
- Conflict detection integration
|
||||
- Empty state messaging
|
||||
- Show All navigation
|
||||
- Event delegation for entity selection
|
||||
- **Props**: `entityType`, `entities`, `maxItems`, `activeDid`, `allMyDids`, `allContacts`, `conflictChecker`, `showYouEntity`, `youSelectable`, `showAllRoute`, `showAllQueryParams`
|
||||
- **Emits**: `entity-selected`
|
||||
|
||||
#### 6. SpecialEntityCard.vue ✅
|
||||
|
||||
- **Purpose**: Handle special entities like "You" and "Unnamed"
|
||||
- **Features**:
|
||||
- Special icon display (hand, question mark)
|
||||
- Conflict state handling
|
||||
- Configurable styling based on entity type
|
||||
- Click event handling
|
||||
- **Props**: `entityType`, `label`, `icon`, `selectable`, `conflicted`, `entityData`
|
||||
- **Emits**: `entity-selected`
|
||||
|
||||
#### 7. ShowAllCard.vue ✅
|
||||
|
||||
- **Purpose**: Handle "Show All" navigation functionality
|
||||
- **Features**:
|
||||
- Router-link integration
|
||||
- Query parameter passing
|
||||
- Consistent visual styling
|
||||
- Hover effects
|
||||
- **Props**: `entityType`, `routeName`, `queryParams`
|
||||
- **Emits**: None (uses router-link)
|
||||
|
||||
### Phase 3: Extract Step Components (✅ COMPLETED)
|
||||
|
||||
These components handle major UI sections:
|
||||
|
||||
#### 8. EntitySelectionStep.vue ✅
|
||||
|
||||
- **Purpose**: Complete step 1 entity selection interface
|
||||
- **Features**:
|
||||
- Dynamic step labeling based on context
|
||||
- EntityGrid integration for unified entity display
|
||||
- Conflict detection and prevention
|
||||
- Special entity handling (You, Unnamed)
|
||||
- Show All navigation with context preservation
|
||||
- Cancel functionality
|
||||
- Event delegation for entity selection
|
||||
- **Props**: `stepType`, `giverEntityType`, `recipientEntityType`, `showProjects`, `isFromProjectView`, `projects`, `allContacts`, `activeDid`, `allMyDids`, `conflictChecker`, `fromProjectId`, `toProjectId`, `giver`, `receiver`
|
||||
- **Emits**: `entity-selected`, `cancel`
|
||||
|
||||
#### 9. GiftDetailsStep.vue ✅
|
||||
|
||||
- **Purpose**: Complete step 2 gift details form interface
|
||||
- **Features**:
|
||||
- Entity summary display with edit capability
|
||||
- Gift description input with placeholder support
|
||||
- Amount input with increment/decrement controls
|
||||
- Unit code selection (HUR, USD, BTC, etc.)
|
||||
- Photo & more options navigation
|
||||
- Conflict detection and warning display
|
||||
- Form validation and submission
|
||||
- Cancel functionality
|
||||
- **Props**: `giver`, `receiver`, `giverEntityType`, `recipientEntityType`, `description`, `amount`, `unitCode`, `prompt`, `isFromProjectView`, `hasConflict`, `offerId`, `fromProjectId`, `toProjectId`
|
||||
- **Emits**: `update:description`, `update:amount`, `update:unitCode`, `edit-entity`, `explain-data`, `submit`, `cancel`
|
||||
|
||||
### Phase 4: Refactor Main Component (FINAL)
|
||||
|
||||
#### 9. GiftedDialog.vue (PLANNED REFACTOR)
|
||||
|
||||
- **Purpose**: Orchestrate sub-components and manage overall state
|
||||
- **Responsibilities**:
|
||||
- Step navigation logic
|
||||
- Entity conflict detection
|
||||
- API integration for gift recording
|
||||
- Success/error handling
|
||||
- Dialog visibility management
|
||||
|
||||
## Implementation Progress
|
||||
|
||||
### ✅ Completed Components
|
||||
|
||||
**Phase 1: Display Components**
|
||||
|
||||
1. **PersonCard.vue** - Individual person display with selection
|
||||
2. **ProjectCard.vue** - Individual project display with selection
|
||||
3. **EntitySummaryButton.vue** - Selected entity display with edit capability
|
||||
4. **AmountInput.vue** - Numeric input with increment/decrement controls
|
||||
|
||||
**Phase 2: Layout Components**
|
||||
5. **EntityGrid.vue** - Unified grid layout for entity selection
|
||||
6. **SpecialEntityCard.vue** - Special entities (You, Unnamed) with conflict handling
|
||||
7. **ShowAllCard.vue** - Show All navigation with router integration
|
||||
|
||||
**Phase 3: Step Components**
|
||||
8. **EntitySelectionStep.vue** - Complete step 1 entity selection interface
|
||||
9. **GiftDetailsStep.vue** - Complete step 2 gift details form interface
|
||||
|
||||
### 🔄 Next Steps
|
||||
|
||||
1. **Update GiftedDialog.vue** - Integrate all Phase 1-3 components
|
||||
2. **Test integration** - Ensure functionality remains intact
|
||||
3. **Create unit tests** - For all new components
|
||||
4. **Performance validation** - Ensure no regression
|
||||
5. **Phase 4 planning** - Refactor main component to orchestration only
|
||||
|
||||
### 📋 Future Phases
|
||||
|
||||
1. **Extract EntitySelectionStep.vue** - Complete step 1 logic
|
||||
2. **Extract GiftDetailsStep.vue** - Complete step 2 logic
|
||||
3. **Refactor main component** - Minimal orchestration logic
|
||||
4. **Add comprehensive tests** - Unit tests for each component
|
||||
5. **Prepare for Pinia** - State management migration
|
||||
|
||||
## Benefits of This Approach
|
||||
|
||||
### 1. Incremental Refactoring
|
||||
|
||||
- Each phase can be implemented and tested independently
|
||||
- Reduces risk of breaking existing functionality
|
||||
- Allows for gradual improvement over time
|
||||
|
||||
### 2. Improved Maintainability
|
||||
- Smaller, focused components are easier to understand
|
||||
- Clear separation of concerns
|
||||
- Easier to locate and fix bugs
|
||||
|
||||
### 3. Enhanced Testability
|
||||
- Individual components can be unit tested in isolation
|
||||
- Easier to mock dependencies
|
||||
- Better test coverage possible
|
||||
|
||||
### 4. Better Reusability
|
||||
- Components like EntityGrid can be used in other views
|
||||
- PersonCard and ProjectCard can be used throughout the app
|
||||
- AmountInput can be reused for other numeric inputs
|
||||
|
||||
### 5. Pinia Preparation
|
||||
- Smaller components make state management migration easier
|
||||
- Clear data flow patterns emerge
|
||||
- Easier to identify what state should be global vs local
|
||||
|
||||
## Integration Examples
|
||||
|
||||
### Using PersonCard in EntityGrid
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<ul class="grid grid-cols-4 sm:grid-cols-5 md:grid-cols-6 gap-x-2 gap-y-4">
|
||||
<PersonCard
|
||||
v-for="person in people"
|
||||
:key="person.did"
|
||||
:person="person"
|
||||
:conflicted="wouldCreateConflict(person.did)"
|
||||
@person-selected="handlePersonSelected"
|
||||
/>
|
||||
</ul>
|
||||
</template>
|
||||
```
|
||||
|
||||
### Using AmountInput in GiftDetailsStep
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<AmountInput
|
||||
:value="amount"
|
||||
:min="0"
|
||||
:max="1000"
|
||||
input-id="gift-amount"
|
||||
@update:value="amount = $event"
|
||||
/>
|
||||
</template>
|
||||
```
|
||||
|
||||
### Using EntitySummaryButton in GiftDetailsStep
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<EntitySummaryButton
|
||||
:entity="giver"
|
||||
entity-type="person"
|
||||
label="Received from:"
|
||||
:editable="canEditGiver"
|
||||
@edit-requested="handleEditGiver"
|
||||
/>
|
||||
<EntitySummaryButton
|
||||
:entity="receiver"
|
||||
entity-type="person"
|
||||
label="Given to:"
|
||||
:editable="canEditReceiver"
|
||||
@edit-requested="handleEditReceiver"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
```
|
||||
|
||||
### Using EntityGrid in EntitySelectionStep
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<div>
|
||||
<label class="block font-bold mb-4">
|
||||
{{ stepLabel }}
|
||||
</label>
|
||||
|
||||
<EntityGrid
|
||||
:entity-type="shouldShowProjects ? 'projects' : 'people'"
|
||||
:entities="shouldShowProjects ? projects : allContacts"
|
||||
:max-items="10"
|
||||
:active-did="activeDid"
|
||||
:all-my-dids="allMyDids"
|
||||
:all-contacts="allContacts"
|
||||
:conflict-checker="wouldCreateConflict"
|
||||
:show-you-entity="showYouEntity"
|
||||
:you-selectable="youSelectable"
|
||||
:show-all-route="showAllRoute"
|
||||
:show-all-query-params="showAllQueryParams"
|
||||
@entity-selected="handleEntitySelected"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
```
|
||||
|
||||
### Using SpecialEntityCard Standalone
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<ul class="grid grid-cols-4 gap-2">
|
||||
<SpecialEntityCard
|
||||
entity-type="you"
|
||||
label="You"
|
||||
icon="hand"
|
||||
:conflicted="wouldCreateConflict(activeDid)"
|
||||
:entity-data="{ did: activeDid, name: 'You' }"
|
||||
@entity-selected="handleYouSelected"
|
||||
/>
|
||||
|
||||
<SpecialEntityCard
|
||||
entity-type="unnamed"
|
||||
label="Unnamed"
|
||||
icon="circle-question"
|
||||
:entity-data="{ did: '', name: 'Unnamed' }"
|
||||
@entity-selected="handleUnnamedSelected"
|
||||
/>
|
||||
</ul>
|
||||
</template>
|
||||
```
|
||||
|
||||
### Using EntitySelectionStep in GiftedDialog
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<div v-show="currentStep === 1">
|
||||
<EntitySelectionStep
|
||||
:step-type="stepType"
|
||||
:giver-entity-type="giverEntityType"
|
||||
:recipient-entity-type="recipientEntityType"
|
||||
:show-projects="showProjects"
|
||||
:is-from-project-view="isFromProjectView"
|
||||
:projects="projects"
|
||||
:all-contacts="allContacts"
|
||||
:active-did="activeDid"
|
||||
:all-my-dids="allMyDids"
|
||||
:conflict-checker="wouldCreateConflict"
|
||||
:from-project-id="fromProjectId"
|
||||
:to-project-id="toProjectId"
|
||||
:giver="giver"
|
||||
:receiver="receiver"
|
||||
@entity-selected="handleEntitySelected"
|
||||
@cancel="cancel"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
```
|
||||
|
||||
### Using GiftDetailsStep in GiftedDialog
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<div v-show="currentStep === 2">
|
||||
<GiftDetailsStep
|
||||
:giver="giver"
|
||||
:receiver="receiver"
|
||||
:giver-entity-type="giverEntityType"
|
||||
:recipient-entity-type="recipientEntityType"
|
||||
:description="description"
|
||||
:amount="parseFloat(amountInput)"
|
||||
:unit-code="unitCode"
|
||||
:prompt="prompt"
|
||||
:is-from-project-view="isFromProjectView"
|
||||
:has-conflict="hasPersonConflict"
|
||||
:offer-id="offerId"
|
||||
:from-project-id="fromProjectId"
|
||||
:to-project-id="toProjectId"
|
||||
@update:description="description = $event"
|
||||
@update:amount="amountInput = $event.toString()"
|
||||
@update:unit-code="unitCode = $event"
|
||||
@edit-entity="handleEditEntity"
|
||||
@explain-data="explainData"
|
||||
@submit="handleSubmit"
|
||||
@cancel="cancel"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
```
|
||||
|
||||
## Migration Strategy
|
||||
|
||||
### Backward Compatibility
|
||||
- Maintain existing API and prop interfaces
|
||||
- Ensure all existing functionality works unchanged
|
||||
- Preserve all event emissions and callbacks
|
||||
|
||||
### Testing Strategy
|
||||
- Create unit tests for each new component
|
||||
- Maintain existing integration tests
|
||||
- Add visual regression tests for UI components
|
||||
|
||||
### Performance Considerations
|
||||
- Monitor bundle size impact
|
||||
- Ensure no performance regression
|
||||
- Optimize component loading if needed
|
||||
|
||||
## Security Considerations
|
||||
|
||||
### Input Validation
|
||||
- AmountInput includes proper numeric validation
|
||||
- All user inputs are validated before processing
|
||||
- XSS prevention through proper Vue templating
|
||||
|
||||
### Data Handling
|
||||
- No sensitive data stored in component state
|
||||
- Proper prop validation and type checking
|
||||
- Secure API communication maintained
|
||||
|
||||
## Conclusion
|
||||
|
||||
This decomposition plan provides a structured approach to refactoring the GiftedDialog component while maintaining functionality and preparing for future enhancements. The incremental approach reduces risk and allows for continuous improvement of the codebase.
|
||||
|
||||
The completed Phase 1 components (PersonCard, ProjectCard, EntitySummaryButton, AmountInput) provide a solid foundation for the remaining phases and demonstrate the benefits of component decomposition in terms of maintainability, testability, and reusability.
|
||||
|
||||
---
|
||||
|
||||
## Final Integration Results
|
||||
|
||||
### ✅ **INTEGRATION COMPLETE**
|
||||
|
||||
**Completed on**: 2025-01-28
|
||||
|
||||
**Results:**
|
||||
- **Main GiftedDialog template**: Reduced from ~200 lines to ~20 lines
|
||||
- **Components created**: 9 focused, reusable components
|
||||
- **Lines of code**: ~2,000 lines of well-structured component code
|
||||
- **Backward compatibility**: 100% maintained
|
||||
- **Build status**: ✅ Passing
|
||||
- **Runtime status**: ✅ Working
|
||||
|
||||
**Components Successfully Integrated:**
|
||||
|
||||
1. **PersonCard.vue** - Individual person display with conflict detection
|
||||
2. **ProjectCard.vue** - Individual project display with issuer info
|
||||
3. **EntitySummaryButton.vue** - Selected entity display with edit capability
|
||||
4. **AmountInput.vue** - Numeric input with validation and controls
|
||||
5. **SpecialEntityCard.vue** - "You" and "Unnamed" entity handling
|
||||
6. **ShowAllCard.vue** - Navigation with router integration
|
||||
7. **EntityGrid.vue** - Unified grid layout orchestration
|
||||
8. **EntitySelectionStep.vue** - Complete Step 1 interface
|
||||
9. **GiftDetailsStep.vue** - Complete Step 2 interface
|
||||
|
||||
**Integration Benefits Achieved:**
|
||||
- ✅ **Maintainability**: Each component has single responsibility
|
||||
- ✅ **Testability**: Components can be unit tested in isolation
|
||||
- ✅ **Reusability**: Components can be used across the application
|
||||
- ✅ **Readability**: Clear separation of concerns and focused logic
|
||||
- ✅ **Debugging**: Easier to identify and fix issues
|
||||
- ✅ **Performance**: No performance regression, improved code splitting
|
||||
|
||||
**Next Steps:**
|
||||
1. **Pinia State Management**: Ready for state management migration
|
||||
2. **Component Testing**: Add comprehensive unit tests
|
||||
3. **Visual Testing**: Add Playwright component tests
|
||||
4. **Documentation**: Update component documentation
|
||||
5. **Optimization**: Fine-tune performance if needed
|
||||
|
||||
---
|
||||
|
||||
**Author**: Matthew Raymer
|
||||
**Last Updated**: 2025-01-28
|
||||
**Status**: ✅ **INTEGRATION COMPLETE - READY FOR PRODUCTION**
|
||||
File diff suppressed because it is too large
Load Diff
@@ -31,8 +31,8 @@ android {
|
||||
applicationId "app.timesafari.app"
|
||||
minSdkVersion rootProject.ext.minSdkVersion
|
||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||
versionCode 26
|
||||
versionName "0.5.1"
|
||||
versionCode 34
|
||||
versionName "0.5.8"
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
aaptOptions {
|
||||
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
|
||||
|
||||
@@ -100,6 +100,7 @@ try {
|
||||
- `src/interfaces/deepLinks.ts`: Type definitions and validation schemas
|
||||
- `src/services/deepLinks.ts`: Deep link processing service
|
||||
- `src/main.capacitor.ts`: Capacitor integration
|
||||
- `src/views/DeepLinkRedirectView.vue`: Page to handle links to both mobile and web
|
||||
|
||||
## Type Safety Examples
|
||||
|
||||
|
||||
@@ -1,224 +0,0 @@
|
||||
# GiftedDialog Complete Documentation
|
||||
|
||||
## Overview
|
||||
|
||||
The GiftedDialog system is a sophisticated multi-step dialog for recording gifts between people and projects in the TimeSafari application. It consists of a main orchestrating component and 9 specialized child components that handle different aspects of the gift recording workflow.
|
||||
|
||||
## Key Features
|
||||
|
||||
- **Two-Step Workflow**: Entity selection → Gift details
|
||||
- **Multi-Entity Support**: People, projects, and special entities
|
||||
- **Conflict Detection**: Prevents invalid gift combinations
|
||||
- **Responsive Design**: Works across all device sizes
|
||||
- **Accessibility**: Full keyboard navigation and screen reader support
|
||||
- **Validation**: Comprehensive form validation and error handling
|
||||
- **Flexible Integration**: Can be embedded in any view with different contexts
|
||||
|
||||
## Component Architecture
|
||||
|
||||
### Main Component
|
||||
- **GiftedDialog.vue** - Main orchestrating component that manages dialog state, step navigation, and API integration
|
||||
|
||||
### Step Components
|
||||
- **EntitySelectionStep.vue** - Step 1 controller with dynamic labeling and context awareness
|
||||
- **GiftDetailsStep.vue** - Step 2 controller with form validation and entity summaries
|
||||
|
||||
### Layout Components
|
||||
- **EntityGrid.vue** - Unified entity grid layout with responsive design
|
||||
- **EntitySummaryButton.vue** - Selected entity display with edit capability
|
||||
|
||||
### Display Components
|
||||
- **PersonCard.vue** - Individual person display with avatar and selection states
|
||||
- **ProjectCard.vue** - Individual project display with icons and issuer information
|
||||
- **SpecialEntityCard.vue** - Special entities (You, Unnamed) with conflict detection
|
||||
- **ShowAllCard.vue** - Navigation component with router integration
|
||||
|
||||
### Input Components
|
||||
- **AmountInput.vue** - Numeric input with increment/decrement controls and validation
|
||||
|
||||
## Data Flow
|
||||
|
||||
### Step 1: Entity Selection
|
||||
1. User opens dialog → GiftedDialog renders EntitySelectionStep
|
||||
2. EntitySelectionStep renders EntityGrid with entities and configuration
|
||||
3. EntityGrid renders PersonCard/ProjectCard/SpecialEntityCard components
|
||||
4. User clicks entity → Card emits to Grid → Grid emits to Step → Step emits to Dialog
|
||||
5. GiftedDialog updates state and advances to step 2
|
||||
|
||||
### Step 2: Gift Details
|
||||
1. GiftedDialog renders GiftDetailsStep with selected entities
|
||||
2. GiftDetailsStep renders EntitySummaryButton and AmountInput components
|
||||
3. User fills form and clicks submit → Step emits to Dialog
|
||||
4. GiftedDialog processes submission via API and handles success/error
|
||||
|
||||
## Key Props and Configuration
|
||||
|
||||
### GiftedDialog Props
|
||||
```typescript
|
||||
interface GiftedDialogProps {
|
||||
fromProjectId?: string; // Project ID when project is giver
|
||||
toProjectId?: string; // Project ID when project is recipient
|
||||
showProjects?: boolean; // Whether to show projects
|
||||
isFromProjectView?: boolean; // Context flag for project views
|
||||
}
|
||||
```
|
||||
|
||||
### Opening the Dialog
|
||||
```typescript
|
||||
// Basic usage
|
||||
giftedDialog.open();
|
||||
|
||||
// With pre-selected entities and context
|
||||
giftedDialog.open(
|
||||
giverEntity, // Pre-selected giver
|
||||
receiverEntity, // Pre-selected receiver
|
||||
offerId, // Offer context
|
||||
customTitle, // Custom dialog title
|
||||
prompt, // Custom input prompt
|
||||
successCallback // Success handler
|
||||
);
|
||||
```
|
||||
|
||||
## Integration Patterns
|
||||
|
||||
### Basic Integration
|
||||
```vue
|
||||
<template>
|
||||
<div>
|
||||
<button @click="openGiftDialog">Record Gift</button>
|
||||
<GiftedDialog ref="giftedDialog" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
export default class Example extends Vue {
|
||||
openGiftDialog() {
|
||||
const dialog = this.$refs.giftedDialog as GiftedDialog;
|
||||
dialog.open();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
```
|
||||
|
||||
### Project Context Integration
|
||||
```typescript
|
||||
// Gift from project
|
||||
dialog.open(
|
||||
projectEntity, // Project as giver
|
||||
undefined, // User selects receiver
|
||||
undefined, // No offer
|
||||
"Gift from Project",
|
||||
"What did this project provide?"
|
||||
);
|
||||
|
||||
// Gift to project
|
||||
dialog.open(
|
||||
undefined, // User selects giver
|
||||
projectEntity, // Project as receiver
|
||||
undefined, // No offer
|
||||
"Gift to Project",
|
||||
"What was contributed to this project?"
|
||||
);
|
||||
```
|
||||
|
||||
## State Management
|
||||
|
||||
The dialog manages internal state through a reactive system:
|
||||
|
||||
- **Step Navigation**: Controls progression from entity selection to gift details
|
||||
- **Entity Selection**: Tracks selected giver and receiver entities
|
||||
- **Conflict Detection**: Prevents selecting same person for both roles
|
||||
- **Form Validation**: Ensures required fields are completed
|
||||
- **API Integration**: Handles gift submission and response processing
|
||||
|
||||
## Conflict Detection Logic
|
||||
|
||||
```typescript
|
||||
function wouldCreateConflict(selectedDid: string): boolean {
|
||||
// Only applies to person-to-person gifts
|
||||
if (giverEntityType !== "person" || recipientEntityType !== "person") {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if selecting same person for both roles
|
||||
if (stepType === "giver") {
|
||||
return receiver?.did === selectedDid;
|
||||
} else if (stepType === "recipient") {
|
||||
return giver?.did === selectedDid;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
```
|
||||
|
||||
## AmountInput Component Fix
|
||||
|
||||
The AmountInput component was recently fixed to resolve an issue where increment/decrement buttons weren't updating the displayed value:
|
||||
|
||||
### Problem
|
||||
- Input field used `:value="displayValue"` (one-way binding)
|
||||
- Programmatic updates to `displayValue` weren't reflected in DOM
|
||||
|
||||
### Solution
|
||||
- Changed to `v-model="displayValue"` (two-way binding)
|
||||
- Now properly synchronizes programmatic and user input changes
|
||||
|
||||
### Usage
|
||||
```vue
|
||||
<AmountInput
|
||||
:value="amount"
|
||||
:min="0"
|
||||
:max="1000"
|
||||
:step="1"
|
||||
@update:value="handleAmountChange"
|
||||
/>
|
||||
```
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
### Unit Testing
|
||||
- Individual component behavior
|
||||
- Props validation
|
||||
- Event emission
|
||||
- Computed property calculations
|
||||
|
||||
### Integration Testing
|
||||
- Multi-component workflows
|
||||
- State management
|
||||
- API integration
|
||||
- Error handling
|
||||
|
||||
### End-to-End Testing
|
||||
- Complete user workflows
|
||||
- Cross-browser compatibility
|
||||
- Accessibility compliance
|
||||
- Performance validation
|
||||
|
||||
## Security Considerations
|
||||
|
||||
- **Input Validation**: All user inputs are sanitized and validated
|
||||
- **DID Privacy**: User identifiers only shared with authorized contacts
|
||||
- **API Security**: Requests are cryptographically signed
|
||||
- **XSS Prevention**: Template sanitization and CSP headers
|
||||
|
||||
## Performance Optimizations
|
||||
|
||||
- **Lazy Loading**: Components loaded only when needed
|
||||
- **Virtual Scrolling**: For large entity lists
|
||||
- **Debounced Input**: Prevents excessive API calls
|
||||
- **Computed Properties**: Efficient reactive calculations
|
||||
- **Memory Management**: Proper cleanup on component destruction
|
||||
|
||||
## Accessibility Features
|
||||
|
||||
- **Keyboard Navigation**: Full tab order and keyboard shortcuts
|
||||
- **Screen Reader Support**: ARIA labels and semantic HTML
|
||||
- **Focus Management**: Proper focus handling on open/close
|
||||
- **High Contrast**: Supports high contrast themes
|
||||
- **Responsive Design**: Works on all screen sizes
|
||||
|
||||
---
|
||||
|
||||
**Author**: Matthew Raymer
|
||||
**Last Updated**: 2025-06-30
|
||||
**Version**: 1.0.0
|
||||
@@ -403,7 +403,7 @@
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 26;
|
||||
CURRENT_PROJECT_VERSION = 34;
|
||||
DEVELOPMENT_TEAM = GM3FS5JQPH;
|
||||
ENABLE_APP_SANDBOX = NO;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
||||
@@ -413,7 +413,7 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 0.5.1;
|
||||
MARKETING_VERSION = 0.5.8;
|
||||
OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\"";
|
||||
PRODUCT_BUNDLE_IDENTIFIER = app.timesafari;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
@@ -430,7 +430,7 @@
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 26;
|
||||
CURRENT_PROJECT_VERSION = 34;
|
||||
DEVELOPMENT_TEAM = GM3FS5JQPH;
|
||||
ENABLE_APP_SANDBOX = NO;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
||||
@@ -440,7 +440,7 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 0.5.1;
|
||||
MARKETING_VERSION = 0.5.8;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = app.timesafari;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "";
|
||||
|
||||
@@ -49,5 +49,16 @@
|
||||
</array>
|
||||
<key>UIViewControllerBasedStatusBarAppearance</key>
|
||||
<true/>
|
||||
<key>CFBundleURLTypes</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>CFBundleURLName</key>
|
||||
<string>app.timesafari</string>
|
||||
<key>CFBundleURLSchemes</key>
|
||||
<array>
|
||||
<string>timesafari</string>
|
||||
</array>
|
||||
</dict>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
935
package-lock.json
generated
935
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "timesafari",
|
||||
"version": "0.5.1",
|
||||
"version": "0.5.8",
|
||||
"description": "Time Safari Application",
|
||||
"author": {
|
||||
"name": "Time Safari Team"
|
||||
|
||||
Binary file not shown.
@@ -1,207 +0,0 @@
|
||||
/** * AmountInput.vue - Specialized amount input with increment/decrement
|
||||
controls * * Extracted from GiftedDialog.vue to handle numeric amount input *
|
||||
with increment/decrement buttons and validation. * * @author Matthew Raymer */
|
||||
<template>
|
||||
<div class="flex flex-grow">
|
||||
<button
|
||||
class="rounded-s border border-e-0 border-slate-400 bg-slate-200 px-4 py-2"
|
||||
:disabled="isAtMinimum"
|
||||
type="button"
|
||||
@click.prevent="decrement"
|
||||
>
|
||||
<font-awesome icon="chevron-left" />
|
||||
</button>
|
||||
|
||||
<input
|
||||
:id="inputId"
|
||||
v-model="displayValue"
|
||||
type="number"
|
||||
:min="min"
|
||||
:max="max"
|
||||
:step="step"
|
||||
class="flex-1 border border-e-0 border-slate-400 px-2 py-2 text-center w-[1px]"
|
||||
@input="handleInput"
|
||||
@blur="handleBlur"
|
||||
/>
|
||||
|
||||
<button
|
||||
class="rounded-e border border-slate-400 bg-slate-200 px-4 py-2"
|
||||
:disabled="isAtMaximum"
|
||||
type="button"
|
||||
@click.prevent="increment"
|
||||
>
|
||||
<font-awesome icon="chevron-right" />
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { Component, Prop, Vue, Watch, Emit } from "vue-facing-decorator";
|
||||
|
||||
/**
|
||||
* AmountInput - Numeric input with increment/decrement controls
|
||||
*
|
||||
* Features:
|
||||
* - Increment/decrement buttons with validation
|
||||
* - Configurable min/max values and step size
|
||||
* - Input validation and formatting
|
||||
* - Disabled state handling for boundary values
|
||||
* - Emits update events for v-model compatibility
|
||||
*/
|
||||
@Component
|
||||
export default class AmountInput extends Vue {
|
||||
/** Current numeric value */
|
||||
@Prop({ required: true })
|
||||
value!: number;
|
||||
|
||||
/** Minimum allowed value */
|
||||
@Prop({ default: 0 })
|
||||
min!: number;
|
||||
|
||||
/** Maximum allowed value */
|
||||
@Prop({ default: Number.MAX_SAFE_INTEGER })
|
||||
max!: number;
|
||||
|
||||
/** Step size for increment/decrement */
|
||||
@Prop({ default: 1 })
|
||||
step!: number;
|
||||
|
||||
/** Input element ID for accessibility */
|
||||
@Prop({ default: "amount-input" })
|
||||
inputId!: string;
|
||||
|
||||
/** Internal display value for input field */
|
||||
private displayValue: string = "0";
|
||||
|
||||
/**
|
||||
* Initialize display value from prop
|
||||
*/
|
||||
mounted(): void {
|
||||
console.log(
|
||||
`[AmountInput] mounted() - initial value: ${this.value}, min: ${this.min}, max: ${this.max}, step: ${this.step}`,
|
||||
);
|
||||
this.displayValue = this.value.toString();
|
||||
console.log(
|
||||
`[AmountInput] mounted() - displayValue set to: ${this.displayValue}`,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Watch for external value changes
|
||||
*/
|
||||
@Watch("value")
|
||||
onValueChange(newValue: number): void {
|
||||
this.displayValue = newValue.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if current value is at minimum
|
||||
*/
|
||||
get isAtMinimum(): boolean {
|
||||
const result = this.value <= this.min;
|
||||
console.log(
|
||||
`[AmountInput] isAtMinimum - value: ${this.value}, min: ${this.min}, result: ${result}`,
|
||||
);
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if current value is at maximum
|
||||
*/
|
||||
get isAtMaximum(): boolean {
|
||||
const result = this.value >= this.max;
|
||||
console.log(
|
||||
`[AmountInput] isAtMaximum - value: ${this.value}, max: ${this.max}, result: ${result}`,
|
||||
);
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Increment the value by step size
|
||||
*/
|
||||
increment(): void {
|
||||
console.log(
|
||||
`[AmountInput] increment() called - current value: ${this.value}, step: ${this.step}`,
|
||||
);
|
||||
const newValue = Math.min(this.value + this.step, this.max);
|
||||
console.log(`[AmountInput] increment() calculated newValue: ${newValue}`);
|
||||
this.updateValue(newValue);
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrement the value by step size
|
||||
*/
|
||||
decrement(): void {
|
||||
console.log(
|
||||
`[AmountInput] decrement() called - current value: ${this.value}, step: ${this.step}`,
|
||||
);
|
||||
const newValue = Math.max(this.value - this.step, this.min);
|
||||
console.log(`[AmountInput] decrement() calculated newValue: ${newValue}`);
|
||||
this.updateValue(newValue);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle direct input changes
|
||||
*/
|
||||
handleInput(): void {
|
||||
const numericValue = parseFloat(this.displayValue);
|
||||
if (!isNaN(numericValue)) {
|
||||
const clampedValue = Math.max(this.min, Math.min(numericValue, this.max));
|
||||
this.updateValue(clampedValue);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle input blur - ensure display value matches actual value
|
||||
*/
|
||||
handleBlur(): void {
|
||||
this.displayValue = this.value.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the value and emit change event
|
||||
*/
|
||||
private updateValue(newValue: number): void {
|
||||
console.log(
|
||||
`[AmountInput] updateValue() called - oldValue: ${this.value}, newValue: ${newValue}`,
|
||||
);
|
||||
if (newValue !== this.value) {
|
||||
console.log(
|
||||
`[AmountInput] updateValue() - values different, updating and emitting`,
|
||||
);
|
||||
this.displayValue = newValue.toString();
|
||||
this.emitUpdateValue(newValue);
|
||||
} else {
|
||||
console.log(`[AmountInput] updateValue() - values same, skipping update`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit update:value event
|
||||
*/
|
||||
@Emit("update:value")
|
||||
emitUpdateValue(value: number): number {
|
||||
console.log(`[AmountInput] emitUpdateValue() - emitting value: ${value}`);
|
||||
return value;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* Remove spinner arrows from number input */
|
||||
input[type="number"]::-webkit-outer-spin-button,
|
||||
input[type="number"]::-webkit-inner-spin-button {
|
||||
-webkit-appearance: none;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
input[type="number"] {
|
||||
-moz-appearance: textfield;
|
||||
}
|
||||
|
||||
/* Disabled button styles */
|
||||
button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
</style>
|
||||
@@ -1,264 +0,0 @@
|
||||
/** * EntityGrid.vue - Unified entity grid layout component * * Extracted from
|
||||
GiftedDialog.vue to provide a reusable grid layout * for displaying people,
|
||||
projects, and special entities with selection. * * @author Matthew Raymer */
|
||||
<template>
|
||||
<ul :class="gridClasses">
|
||||
<!-- Special entities (You, Unnamed) for people grids -->
|
||||
<template v-if="entityType === 'people'">
|
||||
<!-- "You" entity -->
|
||||
<SpecialEntityCard
|
||||
v-if="showYouEntity"
|
||||
entity-type="you"
|
||||
label="You"
|
||||
icon="hand"
|
||||
:selectable="youSelectable"
|
||||
:conflicted="youConflicted"
|
||||
:entity-data="youEntityData"
|
||||
@entity-selected="handleEntitySelected"
|
||||
/>
|
||||
|
||||
<!-- "Unnamed" entity -->
|
||||
<SpecialEntityCard
|
||||
entity-type="unnamed"
|
||||
label="Unnamed"
|
||||
icon="circle-question"
|
||||
:entity-data="unnamedEntityData"
|
||||
@entity-selected="handleEntitySelected"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<!-- Empty state message -->
|
||||
<li
|
||||
v-if="entities.length === 0"
|
||||
class="text-xs text-slate-500 italic col-span-full"
|
||||
>
|
||||
{{ emptyStateMessage }}
|
||||
</li>
|
||||
|
||||
<!-- Entity cards (people or projects) -->
|
||||
<template v-if="entityType === 'people'">
|
||||
<PersonCard
|
||||
v-for="person in displayedEntities"
|
||||
:key="person.did"
|
||||
:person="person"
|
||||
:conflicted="isPersonConflicted(person.did)"
|
||||
:show-time-icon="true"
|
||||
@person-selected="handlePersonSelected"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<template v-else-if="entityType === 'projects'">
|
||||
<ProjectCard
|
||||
v-for="project in displayedEntities"
|
||||
:key="project.handleId"
|
||||
:project="project"
|
||||
:active-did="activeDid"
|
||||
:all-my-dids="allMyDids"
|
||||
:all-contacts="allContacts"
|
||||
@project-selected="handleProjectSelected"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<!-- Show All navigation -->
|
||||
<ShowAllCard
|
||||
v-if="shouldShowAll"
|
||||
:entity-type="entityType"
|
||||
:route-name="showAllRoute"
|
||||
:query-params="showAllQueryParams"
|
||||
/>
|
||||
</ul>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { Component, Prop, Vue, Emit } from "vue-facing-decorator";
|
||||
import PersonCard from "./PersonCard.vue";
|
||||
import ProjectCard from "./ProjectCard.vue";
|
||||
import SpecialEntityCard from "./SpecialEntityCard.vue";
|
||||
import ShowAllCard from "./ShowAllCard.vue";
|
||||
import { Contact } from "../db/tables/contacts";
|
||||
import { PlanData } from "../interfaces/records";
|
||||
|
||||
/**
|
||||
* EntityGrid - Unified grid layout for displaying people or projects
|
||||
*
|
||||
* Features:
|
||||
* - Responsive grid layout for people/projects
|
||||
* - Special entity integration (You, Unnamed)
|
||||
* - Conflict detection integration
|
||||
* - Empty state messaging
|
||||
* - Show All navigation
|
||||
* - Event delegation for entity selection
|
||||
*/
|
||||
@Component({
|
||||
components: {
|
||||
PersonCard,
|
||||
ProjectCard,
|
||||
SpecialEntityCard,
|
||||
ShowAllCard,
|
||||
},
|
||||
})
|
||||
export default class EntityGrid extends Vue {
|
||||
/** Type of entities to display */
|
||||
@Prop({ required: true })
|
||||
entityType!: "people" | "projects";
|
||||
|
||||
/** Array of entities to display */
|
||||
@Prop({ required: true })
|
||||
entities!: Contact[] | PlanData[];
|
||||
|
||||
/** Maximum number of entities to display */
|
||||
@Prop({ default: 10 })
|
||||
maxItems!: number;
|
||||
|
||||
/** Active user's DID */
|
||||
@Prop({ required: true })
|
||||
activeDid!: string;
|
||||
|
||||
/** All user's DIDs */
|
||||
@Prop({ required: true })
|
||||
allMyDids!: string[];
|
||||
|
||||
/** All contacts */
|
||||
@Prop({ required: true })
|
||||
allContacts!: Contact[];
|
||||
|
||||
/** Function to check if a person DID would create a conflict */
|
||||
@Prop({ required: true })
|
||||
conflictChecker!: (did: string) => boolean;
|
||||
|
||||
/** Whether to show the "You" entity for people grids */
|
||||
@Prop({ default: true })
|
||||
showYouEntity!: boolean;
|
||||
|
||||
/** Whether the "You" entity is selectable */
|
||||
@Prop({ default: true })
|
||||
youSelectable!: boolean;
|
||||
|
||||
/** Route name for "Show All" navigation */
|
||||
@Prop({ default: "" })
|
||||
showAllRoute!: string;
|
||||
|
||||
/** Query parameters for "Show All" navigation */
|
||||
@Prop({ default: () => ({}) })
|
||||
showAllQueryParams!: Record<string, any>;
|
||||
|
||||
/**
|
||||
* Computed CSS classes for the grid layout
|
||||
*/
|
||||
get gridClasses(): string {
|
||||
const baseClasses = "grid gap-x-2 gap-y-4 text-center mb-4";
|
||||
|
||||
if (this.entityType === "projects") {
|
||||
return `${baseClasses} grid-cols-3 md:grid-cols-4`;
|
||||
} else {
|
||||
return `${baseClasses} grid-cols-4 sm:grid-cols-5 md:grid-cols-6`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Computed entities to display (limited by maxItems)
|
||||
*/
|
||||
get displayedEntities(): Contact[] | PlanData[] {
|
||||
const maxDisplay = this.entityType === "projects" ? 7 : this.maxItems;
|
||||
return this.entities.slice(0, maxDisplay);
|
||||
}
|
||||
|
||||
/**
|
||||
* Computed empty state message based on entity type
|
||||
*/
|
||||
get emptyStateMessage(): string {
|
||||
if (this.entityType === "projects") {
|
||||
return "(No projects found.)";
|
||||
} else {
|
||||
return "(Add friends to see more people worthy of recognition.)";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether to show the "Show All" navigation
|
||||
*/
|
||||
get shouldShowAll(): boolean {
|
||||
return this.entities.length > 0 && this.showAllRoute !== "";
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the "You" entity is conflicted
|
||||
*/
|
||||
get youConflicted(): boolean {
|
||||
return this.conflictChecker(this.activeDid);
|
||||
}
|
||||
|
||||
/**
|
||||
* Entity data for the "You" special entity
|
||||
*/
|
||||
get youEntityData(): { did: string; name: string } {
|
||||
return {
|
||||
did: this.activeDid,
|
||||
name: "You",
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Entity data for the "Unnamed" special entity
|
||||
*/
|
||||
get unnamedEntityData(): { did: string; name: string } {
|
||||
return {
|
||||
did: "",
|
||||
name: "Unnamed",
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a person DID is conflicted
|
||||
*/
|
||||
isPersonConflicted(did: string): boolean {
|
||||
return this.conflictChecker(did);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle person selection from PersonCard
|
||||
*/
|
||||
handlePersonSelected(person: Contact): void {
|
||||
this.emitEntitySelected({
|
||||
type: "person",
|
||||
data: person,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle project selection from ProjectCard
|
||||
*/
|
||||
handleProjectSelected(project: PlanData): void {
|
||||
this.emitEntitySelected({
|
||||
type: "project",
|
||||
data: project,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle special entity selection from SpecialEntityCard
|
||||
*/
|
||||
handleEntitySelected(event: {
|
||||
type: string;
|
||||
entityType: string;
|
||||
data: any;
|
||||
}): void {
|
||||
this.emitEntitySelected({
|
||||
type: "special",
|
||||
entityType: event.entityType,
|
||||
data: event.data,
|
||||
});
|
||||
}
|
||||
|
||||
// Emit methods using @Emit decorator
|
||||
|
||||
@Emit("entity-selected")
|
||||
emitEntitySelected(data: any): any {
|
||||
return data;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* Grid-specific styles if needed */
|
||||
</style>
|
||||
@@ -1,242 +0,0 @@
|
||||
/** * EntitySelectionStep.vue - Entity selection step component * * Extracted
|
||||
from GiftedDialog.vue to handle the complete step 1 * entity selection interface
|
||||
with dynamic labeling and grid display. * * @author Matthew Raymer */
|
||||
<template>
|
||||
<div id="sectionGiftedGiver">
|
||||
<label class="block font-bold mb-4">
|
||||
{{ stepLabel }}
|
||||
</label>
|
||||
|
||||
<EntityGrid
|
||||
:entity-type="shouldShowProjects ? 'projects' : 'people'"
|
||||
:entities="shouldShowProjects ? projects : allContacts"
|
||||
:max-items="10"
|
||||
:active-did="activeDid"
|
||||
:all-my-dids="allMyDids"
|
||||
:all-contacts="allContacts"
|
||||
:conflict-checker="conflictChecker"
|
||||
:show-you-entity="shouldShowYouEntity"
|
||||
:you-selectable="youSelectable"
|
||||
:show-all-route="showAllRoute"
|
||||
:show-all-query-params="showAllQueryParams"
|
||||
@entity-selected="handleEntitySelected"
|
||||
/>
|
||||
|
||||
<button
|
||||
class="block w-full text-center text-md uppercase bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-1.5 py-2 rounded-lg"
|
||||
@click="handleCancel"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { Component, Prop, Vue, Emit } from "vue-facing-decorator";
|
||||
import EntityGrid from "./EntityGrid.vue";
|
||||
import { Contact } from "../db/tables/contacts";
|
||||
import { PlanData } from "../interfaces/records";
|
||||
|
||||
/**
|
||||
* Entity selection event data structure
|
||||
*/
|
||||
interface EntitySelectionEvent {
|
||||
type: "person" | "project" | "special";
|
||||
entityType?: string;
|
||||
data: any;
|
||||
}
|
||||
|
||||
/**
|
||||
* EntitySelectionStep - Complete step 1 entity selection interface
|
||||
*
|
||||
* Features:
|
||||
* - Dynamic step labeling based on context
|
||||
* - EntityGrid integration for unified entity display
|
||||
* - Conflict detection and prevention
|
||||
* - Special entity handling (You, Unnamed)
|
||||
* - Show All navigation with context preservation
|
||||
* - Cancel functionality
|
||||
* - Event delegation for entity selection
|
||||
*/
|
||||
@Component({
|
||||
components: {
|
||||
EntityGrid,
|
||||
},
|
||||
})
|
||||
export default class EntitySelectionStep extends Vue {
|
||||
/** Type of step: 'giver' or 'recipient' */
|
||||
@Prop({ required: true })
|
||||
stepType!: "giver" | "recipient";
|
||||
|
||||
/** Type of giver entity: 'person' or 'project' */
|
||||
@Prop({ required: true })
|
||||
giverEntityType!: "person" | "project";
|
||||
|
||||
/** Type of recipient entity: 'person' or 'project' */
|
||||
@Prop({ required: true })
|
||||
recipientEntityType!: "person" | "project";
|
||||
|
||||
/** Whether to show projects instead of people */
|
||||
@Prop({ default: false })
|
||||
showProjects!: boolean;
|
||||
|
||||
/** Whether this is from a project view */
|
||||
@Prop({ default: false })
|
||||
isFromProjectView!: boolean;
|
||||
|
||||
/** Array of available projects */
|
||||
@Prop({ required: true })
|
||||
projects!: PlanData[];
|
||||
|
||||
/** Array of available contacts */
|
||||
@Prop({ required: true })
|
||||
allContacts!: Contact[];
|
||||
|
||||
/** Active user's DID */
|
||||
@Prop({ required: true })
|
||||
activeDid!: string;
|
||||
|
||||
/** All user's DIDs */
|
||||
@Prop({ required: true })
|
||||
allMyDids!: string[];
|
||||
|
||||
/** Function to check if a DID would create a conflict */
|
||||
@Prop({ required: true })
|
||||
conflictChecker!: (did: string) => boolean;
|
||||
|
||||
/** Project ID for context (giver) */
|
||||
@Prop({ default: "" })
|
||||
fromProjectId!: string;
|
||||
|
||||
/** Project ID for context (recipient) */
|
||||
@Prop({ default: "" })
|
||||
toProjectId!: string;
|
||||
|
||||
/** Current giver entity for context */
|
||||
@Prop()
|
||||
giver?: any;
|
||||
|
||||
/** Current receiver entity for context */
|
||||
@Prop()
|
||||
receiver?: any;
|
||||
|
||||
/**
|
||||
* Computed step label based on context
|
||||
*/
|
||||
get stepLabel(): string {
|
||||
if (this.stepType === "recipient") {
|
||||
return "Choose who received the gift:";
|
||||
} else if (this.showProjects) {
|
||||
return "Choose a project benefitted from:";
|
||||
} else {
|
||||
return "Choose a person received from:";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether to show projects in the grid
|
||||
*/
|
||||
get shouldShowProjects(): boolean {
|
||||
return (
|
||||
(this.stepType === "giver" && this.giverEntityType === "project") ||
|
||||
(this.stepType === "recipient" && this.recipientEntityType === "project")
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether to show the "You" entity
|
||||
*/
|
||||
get shouldShowYouEntity(): boolean {
|
||||
return (
|
||||
this.stepType === "recipient" ||
|
||||
(this.stepType === "giver" && this.isFromProjectView)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the "You" entity is selectable
|
||||
*/
|
||||
get youSelectable(): boolean {
|
||||
return !this.conflictChecker(this.activeDid);
|
||||
}
|
||||
|
||||
/**
|
||||
* Route name for "Show All" navigation
|
||||
*/
|
||||
get showAllRoute(): string {
|
||||
if (this.shouldShowProjects) {
|
||||
return "discover";
|
||||
} else if (this.allContacts.length > 0) {
|
||||
return "contact-gift";
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
/**
|
||||
* Query parameters for "Show All" navigation
|
||||
*/
|
||||
get showAllQueryParams(): Record<string, any> {
|
||||
if (this.shouldShowProjects) {
|
||||
return {};
|
||||
}
|
||||
|
||||
return {
|
||||
stepType: this.stepType,
|
||||
giverEntityType: this.giverEntityType,
|
||||
recipientEntityType: this.recipientEntityType,
|
||||
...(this.stepType === "giver"
|
||||
? {
|
||||
recipientProjectId: this.toProjectId,
|
||||
recipientProjectName: this.receiver?.name,
|
||||
recipientProjectImage: this.receiver?.image,
|
||||
recipientProjectHandleId: this.receiver?.handleId,
|
||||
recipientDid: this.receiver?.did,
|
||||
}
|
||||
: {
|
||||
giverProjectId: this.fromProjectId,
|
||||
giverProjectName: this.giver?.name,
|
||||
giverProjectImage: this.giver?.image,
|
||||
giverProjectHandleId: this.giver?.handleId,
|
||||
giverDid: this.giver?.did,
|
||||
}),
|
||||
fromProjectId: this.fromProjectId,
|
||||
toProjectId: this.toProjectId,
|
||||
showProjects: this.showProjects.toString(),
|
||||
isFromProjectView: this.isFromProjectView.toString(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle entity selection from EntityGrid
|
||||
*/
|
||||
handleEntitySelected(event: EntitySelectionEvent): void {
|
||||
this.emitEntitySelected({
|
||||
stepType: this.stepType,
|
||||
...event,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle cancel button click
|
||||
*/
|
||||
handleCancel(): void {
|
||||
this.emitCancel();
|
||||
}
|
||||
|
||||
// Emit methods using @Emit decorator
|
||||
|
||||
@Emit("entity-selected")
|
||||
emitEntitySelected(data: any): any {
|
||||
return data;
|
||||
}
|
||||
|
||||
@Emit("cancel")
|
||||
emitCancel(): void {
|
||||
// No return value needed
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* Component-specific styles if needed */
|
||||
</style>
|
||||
@@ -1,145 +0,0 @@
|
||||
/** * EntitySummaryButton.vue - Displays selected entity with edit capability *
|
||||
* Extracted from GiftedDialog.vue to handle entity summary display * in the gift
|
||||
details step with edit functionality. * * @author Matthew Raymer */
|
||||
<template>
|
||||
<component
|
||||
:is="editable ? 'button' : 'div'"
|
||||
class="flex-1 flex items-center gap-2 bg-slate-100 border border-slate-300 rounded-md p-2"
|
||||
@click="handleClick"
|
||||
>
|
||||
<!-- Entity Icon/Avatar -->
|
||||
<div>
|
||||
<template v-if="entityType === 'project'">
|
||||
<ProjectIcon
|
||||
v-if="entity?.handleId"
|
||||
:entity-id="entity.handleId"
|
||||
:icon-size="32"
|
||||
:image-url="entity.image"
|
||||
class="rounded-full bg-white overflow-hidden !size-[2rem] object-cover"
|
||||
/>
|
||||
</template>
|
||||
<template v-else>
|
||||
<EntityIcon
|
||||
v-if="entity?.did"
|
||||
:contact="entity"
|
||||
class="rounded-full bg-white overflow-hidden !size-[2rem] object-cover"
|
||||
/>
|
||||
<font-awesome
|
||||
v-else
|
||||
icon="circle-question"
|
||||
class="text-slate-400 text-3xl"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Entity Information -->
|
||||
<div class="text-start min-w-0">
|
||||
<p class="text-xs text-slate-500 leading-1 -mb-1 uppercase">
|
||||
{{ label }}
|
||||
</p>
|
||||
<h3 class="font-semibold truncate">
|
||||
{{ entity?.name || "Unnamed" }}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<!-- Edit/Lock Icon -->
|
||||
<p class="ms-auto text-sm pe-1" :class="iconClasses">
|
||||
<font-awesome
|
||||
:icon="editable ? 'pen' : 'lock'"
|
||||
:title="editable ? 'Change' : 'Can\'t be changed'"
|
||||
/>
|
||||
</p>
|
||||
</component>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { Component, Prop, Vue, Emit } from "vue-facing-decorator";
|
||||
import EntityIcon from "./EntityIcon.vue";
|
||||
import ProjectIcon from "./ProjectIcon.vue";
|
||||
import { Contact } from "../db/tables/contacts";
|
||||
|
||||
/**
|
||||
* Entity interface for both person and project entities
|
||||
*/
|
||||
interface EntityData {
|
||||
did?: string;
|
||||
handleId?: string;
|
||||
name?: string;
|
||||
image?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* EntitySummaryButton - Displays selected entity with optional edit capability
|
||||
*
|
||||
* Features:
|
||||
* - Shows entity avatar (person or project)
|
||||
* - Displays entity name and role label
|
||||
* - Handles editable vs locked states
|
||||
* - Emits edit events when clicked and editable
|
||||
* - Supports both person and project entity types
|
||||
*/
|
||||
@Component({
|
||||
components: {
|
||||
EntityIcon,
|
||||
ProjectIcon,
|
||||
},
|
||||
})
|
||||
export default class EntitySummaryButton extends Vue {
|
||||
/** Entity data to display */
|
||||
@Prop({ required: true })
|
||||
entity!: EntityData | Contact | null;
|
||||
|
||||
/** Type of entity: 'person' or 'project' */
|
||||
@Prop({ required: true })
|
||||
entityType!: "person" | "project";
|
||||
|
||||
/** Display label for the entity role */
|
||||
@Prop({ required: true })
|
||||
label!: string;
|
||||
|
||||
/** Whether the entity can be edited */
|
||||
@Prop({ default: true })
|
||||
editable!: boolean;
|
||||
|
||||
/**
|
||||
* Computed CSS classes for the edit/lock icon
|
||||
*/
|
||||
get iconClasses(): string {
|
||||
return this.editable ? "text-blue-500" : "text-slate-400";
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle click event - only emit if editable
|
||||
*/
|
||||
handleClick(): void {
|
||||
if (this.editable) {
|
||||
this.emitEditRequested({
|
||||
entityType: this.entityType,
|
||||
entity: this.entity,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Emit methods using @Emit decorator
|
||||
|
||||
@Emit("edit-requested")
|
||||
emitEditRequested(data: any): any {
|
||||
return data;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* Ensure button styling is consistent */
|
||||
button {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
button:hover {
|
||||
background-color: #f1f5f9; /* hover:bg-slate-100 */
|
||||
}
|
||||
|
||||
div {
|
||||
cursor: default;
|
||||
}
|
||||
</style>
|
||||
@@ -1,414 +0,0 @@
|
||||
/** * GiftDetailsStep.vue - Gift details step component * * Extracted from
|
||||
GiftedDialog.vue to handle the complete step 2 * gift details form interface
|
||||
with entity summaries and validation. * * @author Matthew Raymer */
|
||||
<template>
|
||||
<div id="sectionGiftedGift">
|
||||
<!-- Entity Summary Buttons -->
|
||||
<div class="grid grid-cols-2 gap-2 mb-4">
|
||||
<!-- Giver Button -->
|
||||
<EntitySummaryButton
|
||||
:entity="giver"
|
||||
:entity-type="giverEntityType"
|
||||
:label="giverLabel"
|
||||
:editable="canEditGiver"
|
||||
@edit-requested="handleEditGiver"
|
||||
/>
|
||||
|
||||
<!-- Recipient Button -->
|
||||
<EntitySummaryButton
|
||||
:entity="receiver"
|
||||
:entity-type="recipientEntityType"
|
||||
:label="recipientLabel"
|
||||
:editable="canEditRecipient"
|
||||
@edit-requested="handleEditRecipient"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Gift Description Input -->
|
||||
<input
|
||||
v-model="localDescription"
|
||||
type="text"
|
||||
class="block w-full rounded border border-slate-400 px-3 py-2 mb-4 placeholder:italic"
|
||||
:placeholder="prompt || 'What was given?'"
|
||||
@input="handleDescriptionChange"
|
||||
/>
|
||||
|
||||
<!-- Amount Input and Unit Selection -->
|
||||
<div class="flex mb-4">
|
||||
<AmountInput
|
||||
:value="localAmount"
|
||||
:min="0"
|
||||
input-id="inputGivenAmount"
|
||||
@update:value="handleAmountChange"
|
||||
/>
|
||||
|
||||
<select
|
||||
v-model="localUnitCode"
|
||||
class="flex-1 rounded border border-slate-400 ms-2 px-3 py-2"
|
||||
@change="handleUnitCodeChange"
|
||||
>
|
||||
<option value="HUR">Hours</option>
|
||||
<option value="USD">US $</option>
|
||||
<option value="BTC">BTC</option>
|
||||
<option value="BX">BX</option>
|
||||
<option value="ETH">ETH</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Photo & More Options Link -->
|
||||
<router-link
|
||||
:to="photoOptionsRoute"
|
||||
class="block w-full text-center text-md uppercase bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-1.5 py-2 rounded-lg mb-4"
|
||||
>
|
||||
Photo & more options…
|
||||
</router-link>
|
||||
|
||||
<!-- Sign & Send Info -->
|
||||
<p class="text-center text-sm mb-4">
|
||||
<b class="font-medium">Sign & Send</b> to publish to the world
|
||||
<font-awesome
|
||||
icon="circle-info"
|
||||
class="fa-fw text-blue-500 text-base cursor-pointer"
|
||||
@click="handleExplainData"
|
||||
/>
|
||||
</p>
|
||||
|
||||
<!-- Conflict Warning -->
|
||||
<div
|
||||
v-if="hasConflict"
|
||||
class="mb-4 p-3 bg-red-50 border border-red-200 rounded-md"
|
||||
>
|
||||
<p class="text-red-700 text-sm text-center">
|
||||
<font-awesome icon="exclamation-triangle" class="fa-fw mr-1" />
|
||||
Cannot record: Same person selected as both giver and recipient
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Action Buttons -->
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-2">
|
||||
<button
|
||||
:disabled="hasConflict"
|
||||
:class="submitButtonClasses"
|
||||
@click="handleSubmit"
|
||||
>
|
||||
Sign & Send
|
||||
</button>
|
||||
<button
|
||||
class="block w-full text-center text-md uppercase bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-1.5 py-2 rounded-lg"
|
||||
@click="handleCancel"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { Component, Prop, Vue, Watch, Emit } from "vue-facing-decorator";
|
||||
import EntitySummaryButton from "./EntitySummaryButton.vue";
|
||||
import AmountInput from "./AmountInput.vue";
|
||||
import { RouteLocationRaw } from "vue-router";
|
||||
|
||||
/**
|
||||
* Entity data interface for giver/receiver
|
||||
*/
|
||||
interface EntityData {
|
||||
did?: string;
|
||||
handleId?: string;
|
||||
name?: string;
|
||||
image?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* GiftDetailsStep - Complete step 2 gift details form interface
|
||||
*
|
||||
* Features:
|
||||
* - Entity summary display with edit capability
|
||||
* - Gift description input with placeholder support
|
||||
* - Amount input with increment/decrement controls
|
||||
* - Unit code selection (HUR, USD, BTC, etc.)
|
||||
* - Photo & more options navigation
|
||||
* - Conflict detection and warning display
|
||||
* - Form validation and submission
|
||||
* - Cancel functionality
|
||||
*/
|
||||
@Component({
|
||||
components: {
|
||||
EntitySummaryButton,
|
||||
AmountInput,
|
||||
},
|
||||
})
|
||||
export default class GiftDetailsStep extends Vue {
|
||||
/** Giver entity data */
|
||||
@Prop({ required: true })
|
||||
giver!: EntityData | null;
|
||||
|
||||
/** Receiver entity data */
|
||||
@Prop({ required: true })
|
||||
receiver!: EntityData | null;
|
||||
|
||||
/** Type of giver entity: 'person' or 'project' */
|
||||
@Prop({ required: true })
|
||||
giverEntityType!: "person" | "project";
|
||||
|
||||
/** Type of recipient entity: 'person' or 'project' */
|
||||
@Prop({ required: true })
|
||||
recipientEntityType!: "person" | "project";
|
||||
|
||||
/** Gift description */
|
||||
@Prop({ default: "" })
|
||||
description!: string;
|
||||
|
||||
/** Gift amount */
|
||||
@Prop({ default: 0 })
|
||||
amount!: number;
|
||||
|
||||
/** Unit code (HUR, USD, etc.) */
|
||||
@Prop({ default: "HUR" })
|
||||
unitCode!: string;
|
||||
|
||||
/** Input placeholder text */
|
||||
@Prop({ default: "" })
|
||||
prompt!: string;
|
||||
|
||||
/** Whether this is from a project view */
|
||||
@Prop({ default: false })
|
||||
isFromProjectView!: boolean;
|
||||
|
||||
/** Whether there's a conflict between giver and receiver */
|
||||
@Prop({ default: false })
|
||||
hasConflict!: boolean;
|
||||
|
||||
/** Offer ID for context */
|
||||
@Prop({ default: "" })
|
||||
offerId!: string;
|
||||
|
||||
/** Project ID for context (giver) */
|
||||
@Prop({ default: "" })
|
||||
fromProjectId!: string;
|
||||
|
||||
/** Project ID for context (recipient) */
|
||||
@Prop({ default: "" })
|
||||
toProjectId!: string;
|
||||
|
||||
/** Local reactive copies of props for v-model */
|
||||
private localDescription: string = "";
|
||||
private localAmount: number = 0;
|
||||
private localUnitCode: string = "HUR";
|
||||
|
||||
/**
|
||||
* Initialize local values from props
|
||||
*/
|
||||
mounted(): void {
|
||||
this.localDescription = this.description;
|
||||
this.localAmount = this.amount;
|
||||
this.localUnitCode = this.unitCode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Watch for external prop changes
|
||||
*/
|
||||
@Watch("description")
|
||||
onDescriptionChange(newValue: string): void {
|
||||
this.localDescription = newValue;
|
||||
}
|
||||
|
||||
@Watch("amount")
|
||||
onAmountChange(newValue: number): void {
|
||||
this.localAmount = newValue;
|
||||
}
|
||||
|
||||
@Watch("unitCode")
|
||||
onUnitCodeChange(newValue: string): void {
|
||||
this.localUnitCode = newValue;
|
||||
}
|
||||
|
||||
/**
|
||||
* Computed label for giver entity
|
||||
*/
|
||||
get giverLabel(): string {
|
||||
return this.giverEntityType === "project"
|
||||
? "Benefited from:"
|
||||
: "Received from:";
|
||||
}
|
||||
|
||||
/**
|
||||
* Computed label for recipient entity
|
||||
*/
|
||||
get recipientLabel(): string {
|
||||
return this.recipientEntityType === "project"
|
||||
? "Given to project:"
|
||||
: "Given to:";
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the giver can be edited
|
||||
*/
|
||||
get canEditGiver(): boolean {
|
||||
return !(this.isFromProjectView && this.giverEntityType === "project");
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the recipient can be edited
|
||||
*/
|
||||
get canEditRecipient(): boolean {
|
||||
return this.recipientEntityType === "person";
|
||||
}
|
||||
|
||||
/**
|
||||
* Computed CSS classes for submit button
|
||||
*/
|
||||
get submitButtonClasses(): string {
|
||||
if (this.hasConflict) {
|
||||
return "block w-full text-center text-md uppercase font-bold bg-gradient-to-b from-slate-300 to-slate-500 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-slate-400 px-1.5 py-2 rounded-lg cursor-not-allowed";
|
||||
}
|
||||
return "block w-full text-center text-md uppercase font-bold bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-1.5 py-2 rounded-lg";
|
||||
}
|
||||
|
||||
/**
|
||||
* Computed route for photo & more options
|
||||
*/
|
||||
get photoOptionsRoute(): RouteLocationRaw {
|
||||
return {
|
||||
name: "gifted-details",
|
||||
query: {
|
||||
amountInput: this.localAmount.toString(),
|
||||
description: this.localDescription,
|
||||
giverDid:
|
||||
this.giverEntityType === "person" ? this.giver?.did : undefined,
|
||||
giverName: this.giver?.name,
|
||||
offerId: this.offerId,
|
||||
fulfillsProjectId:
|
||||
this.giverEntityType === "person" &&
|
||||
this.recipientEntityType === "project"
|
||||
? this.toProjectId
|
||||
: undefined,
|
||||
providerProjectId:
|
||||
this.giverEntityType === "project" &&
|
||||
this.recipientEntityType === "person"
|
||||
? this.giver?.handleId
|
||||
: this.fromProjectId,
|
||||
recipientDid: this.receiver?.did,
|
||||
recipientName: this.receiver?.name,
|
||||
unitCode: this.localUnitCode,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle description input changes
|
||||
*/
|
||||
handleDescriptionChange(): void {
|
||||
this.emitUpdateDescription(this.localDescription);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle amount input changes
|
||||
*/
|
||||
handleAmountChange(newAmount: number): void {
|
||||
console.log(
|
||||
`[GiftDetailsStep] handleAmountChange() called - oldAmount: ${this.localAmount}, newAmount: ${newAmount}`,
|
||||
);
|
||||
this.localAmount = newAmount;
|
||||
this.emitUpdateAmount(newAmount);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle unit code selection changes
|
||||
*/
|
||||
handleUnitCodeChange(): void {
|
||||
this.emitUpdateUnitCode(this.localUnitCode);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle giver edit request
|
||||
*/
|
||||
handleEditGiver(): void {
|
||||
this.emitEditEntity({
|
||||
entityType: "giver",
|
||||
currentEntity: this.giver,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle recipient edit request
|
||||
*/
|
||||
handleEditRecipient(): void {
|
||||
this.emitEditEntity({
|
||||
entityType: "recipient",
|
||||
currentEntity: this.receiver,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle explain data info click
|
||||
*/
|
||||
handleExplainData(): void {
|
||||
this.emitExplainData();
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle form submission
|
||||
*/
|
||||
handleSubmit(): void {
|
||||
if (!this.hasConflict) {
|
||||
this.emitSubmit({
|
||||
description: this.localDescription,
|
||||
amount: this.localAmount,
|
||||
unitCode: this.localUnitCode,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle cancel button click
|
||||
*/
|
||||
handleCancel(): void {
|
||||
this.emitCancel();
|
||||
}
|
||||
|
||||
// Emit methods using @Emit decorator
|
||||
|
||||
@Emit("update:description")
|
||||
emitUpdateDescription(description: string): string {
|
||||
return description;
|
||||
}
|
||||
|
||||
@Emit("update:amount")
|
||||
emitUpdateAmount(amount: number): number {
|
||||
console.log(
|
||||
`[GiftDetailsStep] emitUpdateAmount() - emitting amount: ${amount}`,
|
||||
);
|
||||
return amount;
|
||||
}
|
||||
|
||||
@Emit("update:unitCode")
|
||||
emitUpdateUnitCode(unitCode: string): string {
|
||||
return unitCode;
|
||||
}
|
||||
|
||||
@Emit("edit-entity")
|
||||
emitEditEntity(data: any): any {
|
||||
return data;
|
||||
}
|
||||
|
||||
@Emit("explain-data")
|
||||
emitExplainData(): void {
|
||||
// No return value needed
|
||||
}
|
||||
|
||||
@Emit("submit")
|
||||
emitSubmit(data: any): any {
|
||||
return data;
|
||||
}
|
||||
|
||||
@Emit("cancel")
|
||||
emitCancel(): void {
|
||||
// No return value needed
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* Component-specific styles if needed */
|
||||
</style>
|
||||
@@ -1,64 +1,99 @@
|
||||
<template>
|
||||
<div v-if="visible" class="dialog-overlay">
|
||||
<div class="dialog">
|
||||
<!-- Step 1: Entity Selection -->
|
||||
<EntitySelectionStep
|
||||
v-show="currentStep === 1"
|
||||
:step-type="stepType"
|
||||
:giver-entity-type="giverEntityType"
|
||||
:recipient-entity-type="recipientEntityType"
|
||||
:show-projects="showProjects"
|
||||
:is-from-project-view="isFromProjectView"
|
||||
:projects="projects"
|
||||
:all-contacts="allContacts"
|
||||
:active-did="activeDid"
|
||||
:all-my-dids="allMyDids"
|
||||
:conflict-checker="wouldCreateConflict"
|
||||
:from-project-id="fromProjectId"
|
||||
:to-project-id="toProjectId"
|
||||
:giver="giver"
|
||||
:receiver="receiver"
|
||||
@entity-selected="handleEntitySelected"
|
||||
@cancel="cancel"
|
||||
/>
|
||||
|
||||
<!-- Step 2: Gift Details -->
|
||||
<GiftDetailsStep
|
||||
v-show="currentStep === 2"
|
||||
:giver="giver"
|
||||
:receiver="receiver"
|
||||
:giver-entity-type="giverEntityType"
|
||||
:recipient-entity-type="recipientEntityType"
|
||||
:description="description"
|
||||
:amount="parseFloat(amountInput) || 0"
|
||||
:unit-code="unitCode"
|
||||
:prompt="prompt"
|
||||
:is-from-project-view="isFromProjectView"
|
||||
:has-conflict="hasPersonConflict"
|
||||
:offer-id="offerId"
|
||||
:from-project-id="fromProjectId"
|
||||
:to-project-id="toProjectId"
|
||||
@update:description="description = $event"
|
||||
@update:amount="handleAmountUpdate"
|
||||
@update:unit-code="unitCode = $event"
|
||||
@edit-entity="handleEditEntity"
|
||||
@explain-data="explainData"
|
||||
@submit="handleSubmit"
|
||||
@cancel="cancel"
|
||||
<h1 class="text-xl font-bold text-center mb-4">
|
||||
{{ customTitle }}
|
||||
</h1>
|
||||
<input
|
||||
v-model="description"
|
||||
type="text"
|
||||
class="block w-full rounded border border-slate-400 mb-2 px-3 py-2"
|
||||
:placeholder="prompt || 'What was given?'"
|
||||
/>
|
||||
<div class="flex flex-row justify-center">
|
||||
<span
|
||||
class="rounded-l border border-r-0 border-slate-400 bg-slate-200 text-center text-blue-500 px-2 py-2 w-20"
|
||||
@click="changeUnitCode()"
|
||||
>
|
||||
{{ libsUtil.UNIT_SHORT[unitCode] || unitCode }}
|
||||
</span>
|
||||
<div
|
||||
class="border border-r-0 border-slate-400 bg-slate-200 px-4 py-2"
|
||||
@click="amountInput === '0' ? null : decrement()"
|
||||
>
|
||||
<font-awesome icon="chevron-left" />
|
||||
</div>
|
||||
<input
|
||||
id="inputGivenAmount"
|
||||
v-model="amountInput"
|
||||
type="number"
|
||||
class="border border-r-0 border-slate-400 px-2 py-2 text-center w-20"
|
||||
/>
|
||||
<div
|
||||
class="rounded-r border border-slate-400 bg-slate-200 px-4 py-2"
|
||||
@click="increment()"
|
||||
>
|
||||
<font-awesome icon="chevron-right" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-4 flex justify-center">
|
||||
<span>
|
||||
<router-link
|
||||
:to="{
|
||||
name: 'gifted-details',
|
||||
query: {
|
||||
amountInput,
|
||||
description,
|
||||
giverDid: giver?.did,
|
||||
giverName: giver?.name,
|
||||
offerId,
|
||||
fulfillsProjectId: toProjectId,
|
||||
providerProjectId: fromProjectId,
|
||||
recipientDid: receiver?.did,
|
||||
recipientName: receiver?.name,
|
||||
unitCode,
|
||||
},
|
||||
}"
|
||||
class="text-blue-500"
|
||||
>
|
||||
Photo & more options ...
|
||||
</router-link>
|
||||
</span>
|
||||
</div>
|
||||
<p class="text-center mb-2 mt-6 italic">
|
||||
Sign & Send to publish to the world
|
||||
<font-awesome
|
||||
icon="circle-info"
|
||||
class="pl-2 text-blue-500 cursor-pointer"
|
||||
@click="explainData()"
|
||||
/>
|
||||
</p>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-2">
|
||||
<button
|
||||
class="block w-full text-center text-lg font-bold uppercase bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-2 py-3 rounded-md"
|
||||
@click="confirm"
|
||||
>
|
||||
Sign & Send
|
||||
</button>
|
||||
<button
|
||||
class="block w-full text-center text-md uppercase bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-1.5 py-2 rounded-md"
|
||||
@click="cancel"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { Vue, Component, Prop, Watch } from "vue-facing-decorator";
|
||||
import { Vue, Component, Prop } from "vue-facing-decorator";
|
||||
|
||||
import { NotificationIface, USE_DEXIE_DB } from "../constants/app";
|
||||
import {
|
||||
createAndSubmitGive,
|
||||
didInfo,
|
||||
serverMessageForUser,
|
||||
getHeaders,
|
||||
} from "../libs/endorserServer";
|
||||
import * as libsUtil from "../libs/util";
|
||||
import { db, retrieveSettingsForActiveAccount } from "../db/index";
|
||||
@@ -67,42 +102,13 @@ import * as databaseUtil from "../db/databaseUtil";
|
||||
import { retrieveAccountDids } from "../libs/util";
|
||||
import { logger } from "../utils/logger";
|
||||
import { PlatformServiceFactory } from "@/services/PlatformServiceFactory";
|
||||
import EntityIcon from "../components/EntityIcon.vue";
|
||||
import ProjectIcon from "../components/ProjectIcon.vue";
|
||||
import EntitySelectionStep from "../components/EntitySelectionStep.vue";
|
||||
import GiftDetailsStep from "../components/GiftDetailsStep.vue";
|
||||
import { PlanData } from "../interfaces/records";
|
||||
|
||||
@Component({
|
||||
components: {
|
||||
EntityIcon,
|
||||
ProjectIcon,
|
||||
EntitySelectionStep,
|
||||
GiftDetailsStep,
|
||||
},
|
||||
})
|
||||
@Component
|
||||
export default class GiftedDialog extends Vue {
|
||||
$notify!: (notification: NotificationIface, timeout?: number) => void;
|
||||
|
||||
@Prop() fromProjectId = "";
|
||||
@Prop() toProjectId = "";
|
||||
@Prop({ default: false }) showProjects = false;
|
||||
@Prop() isFromProjectView = false;
|
||||
|
||||
@Watch("showProjects")
|
||||
onShowProjectsChange() {
|
||||
this.updateEntityTypes();
|
||||
}
|
||||
|
||||
@Watch("fromProjectId")
|
||||
onFromProjectIdChange() {
|
||||
this.updateEntityTypes();
|
||||
}
|
||||
|
||||
@Watch("toProjectId")
|
||||
onToProjectIdChange() {
|
||||
this.updateEntityTypes();
|
||||
}
|
||||
|
||||
activeDid = "";
|
||||
allContacts: Array<Contact> = [];
|
||||
@@ -119,94 +125,9 @@ export default class GiftedDialog extends Vue {
|
||||
receiver?: libsUtil.GiverReceiverInputInfo;
|
||||
unitCode = "HUR";
|
||||
visible = false;
|
||||
currentStep = 1;
|
||||
|
||||
libsUtil = libsUtil;
|
||||
|
||||
projects: PlanData[] = [];
|
||||
|
||||
didInfo = didInfo;
|
||||
|
||||
// Computed property to help debug template logic
|
||||
get shouldShowProjects() {
|
||||
const result =
|
||||
(this.stepType === "giver" && this.giverEntityType === "project") ||
|
||||
(this.stepType === "recipient" && this.recipientEntityType === "project");
|
||||
return result;
|
||||
}
|
||||
|
||||
// Computed property to check if current selection would create a conflict
|
||||
get hasPersonConflict() {
|
||||
// Only check for conflicts when both entities are persons
|
||||
if (
|
||||
this.giverEntityType !== "person" ||
|
||||
this.recipientEntityType !== "person"
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if giver and recipient are the same person
|
||||
if (
|
||||
this.giver?.did &&
|
||||
this.receiver?.did &&
|
||||
this.giver.did === this.receiver.did
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
// Computed property to check if a contact would create a conflict when selected
|
||||
wouldCreateConflict(contactDid: string) {
|
||||
// Only check for conflicts when both entities are persons
|
||||
if (
|
||||
this.giverEntityType !== "person" ||
|
||||
this.recipientEntityType !== "person"
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (this.stepType === "giver") {
|
||||
// If selecting as giver, check if it conflicts with current recipient
|
||||
return this.receiver?.did === contactDid;
|
||||
} else if (this.stepType === "recipient") {
|
||||
// If selecting as recipient, check if it conflicts with current giver
|
||||
return this.giver?.did === contactDid;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
stepType = "giver";
|
||||
giverEntityType = "person" as "person" | "project";
|
||||
recipientEntityType = "person" as "person" | "project";
|
||||
|
||||
updateEntityTypes() {
|
||||
// Reset and set entity types based on current context
|
||||
this.giverEntityType = "person";
|
||||
this.recipientEntityType = "person";
|
||||
|
||||
// Determine entity types based on current context
|
||||
if (this.showProjects) {
|
||||
// HomeView "Project" button or ProjectViewView "Given by This"
|
||||
this.giverEntityType = "project";
|
||||
this.recipientEntityType = "person";
|
||||
} else if (this.fromProjectId) {
|
||||
// ProjectViewView "Given by This" button (project is giver)
|
||||
this.giverEntityType = "project";
|
||||
this.recipientEntityType = "person";
|
||||
} else if (this.toProjectId) {
|
||||
// ProjectViewView "Given to This" button (project is recipient)
|
||||
this.giverEntityType = "person";
|
||||
this.recipientEntityType = "project";
|
||||
} else {
|
||||
// HomeView "Person" button
|
||||
this.giverEntityType = "person";
|
||||
this.recipientEntityType = "person";
|
||||
}
|
||||
}
|
||||
|
||||
async open(
|
||||
giver?: libsUtil.GiverReceiverInputInfo,
|
||||
receiver?: libsUtil.GiverReceiverInputInfo,
|
||||
@@ -219,14 +140,10 @@ export default class GiftedDialog extends Vue {
|
||||
this.giver = giver;
|
||||
this.prompt = prompt || "";
|
||||
this.receiver = receiver;
|
||||
// if we show "given to user" selection, default checkbox to true
|
||||
this.amountInput = "0";
|
||||
this.callbackOnSuccess = callbackOnSuccess;
|
||||
this.offerId = offerId || "";
|
||||
this.currentStep = giver ? 2 : 1;
|
||||
this.stepType = "giver";
|
||||
|
||||
// Update entity types based on current props
|
||||
this.updateEntityTypes();
|
||||
|
||||
try {
|
||||
let settings = await databaseUtil.retrieveSettingsForActiveAccount();
|
||||
@@ -257,16 +174,7 @@ export default class GiftedDialog extends Vue {
|
||||
this.allContacts,
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
this.giverEntityType === "project" ||
|
||||
this.recipientEntityType === "project"
|
||||
) {
|
||||
await this.loadProjects();
|
||||
} else {
|
||||
// Clear projects array when not needed
|
||||
this.projects = [];
|
||||
}
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
} catch (err: any) {
|
||||
logger.error("Error retrieving settings from database:", err);
|
||||
this.$notify(
|
||||
@@ -316,7 +224,6 @@ export default class GiftedDialog extends Vue {
|
||||
this.amountInput = "0";
|
||||
this.prompt = "";
|
||||
this.unitCode = "HUR";
|
||||
this.currentStep = 1;
|
||||
}
|
||||
|
||||
async confirm() {
|
||||
@@ -359,20 +266,6 @@ export default class GiftedDialog extends Vue {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check for person conflict
|
||||
if (this.hasPersonConflict) {
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Error",
|
||||
text: "You cannot select the same person as both giver and recipient.",
|
||||
},
|
||||
3000,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
this.close();
|
||||
this.$notify(
|
||||
{
|
||||
@@ -411,56 +304,24 @@ export default class GiftedDialog extends Vue {
|
||||
unitCode: string = "HUR",
|
||||
) {
|
||||
try {
|
||||
// Determine the correct parameters based on entity types
|
||||
let fromDid: string | undefined;
|
||||
let toDid: string | undefined;
|
||||
let fulfillsProjectHandleId: string | undefined;
|
||||
let providerPlanHandleId: string | undefined;
|
||||
|
||||
if (
|
||||
this.giverEntityType === "project" &&
|
||||
this.recipientEntityType === "person"
|
||||
) {
|
||||
// Project-to-person gift
|
||||
fromDid = undefined; // No person giver
|
||||
toDid = recipientDid as string; // Person recipient
|
||||
fulfillsProjectHandleId = undefined; // No project recipient
|
||||
providerPlanHandleId = this.giver?.handleId; // Project giver
|
||||
} else if (
|
||||
this.giverEntityType === "person" &&
|
||||
this.recipientEntityType === "project"
|
||||
) {
|
||||
// Person-to-project gift
|
||||
fromDid = giverDid as string; // Person giver
|
||||
toDid = undefined; // No person recipient
|
||||
fulfillsProjectHandleId = this.toProjectId; // Project recipient
|
||||
providerPlanHandleId = undefined; // No project giver
|
||||
} else {
|
||||
// Person-to-person gift
|
||||
fromDid = giverDid as string;
|
||||
toDid = recipientDid as string;
|
||||
fulfillsProjectHandleId = undefined;
|
||||
providerPlanHandleId = undefined;
|
||||
}
|
||||
|
||||
const result = await createAndSubmitGive(
|
||||
this.axios,
|
||||
this.apiServer,
|
||||
this.activeDid,
|
||||
fromDid,
|
||||
toDid,
|
||||
giverDid as string,
|
||||
recipientDid as string,
|
||||
description,
|
||||
amount,
|
||||
unitCode,
|
||||
fulfillsProjectHandleId,
|
||||
this.toProjectId,
|
||||
this.offerId,
|
||||
false,
|
||||
undefined,
|
||||
providerPlanHandleId,
|
||||
this.fromProjectId,
|
||||
);
|
||||
|
||||
if (!result.success) {
|
||||
const errorMessage = this.getGiveCreationErrorMessage(result);
|
||||
const errorMessage = result.error;
|
||||
logger.error("Error with give creation result:", result);
|
||||
this.$notify(
|
||||
{
|
||||
@@ -506,19 +367,6 @@ export default class GiftedDialog extends Vue {
|
||||
|
||||
// Helper functions for readability
|
||||
|
||||
/**
|
||||
* @param result direct response eg. ErrorResult or SuccessResult (potentially with embedded "data")
|
||||
* @returns best guess at an error message
|
||||
*/
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
getGiveCreationErrorMessage(result: any) {
|
||||
return (
|
||||
result.error?.userMessage ||
|
||||
result.error?.error ||
|
||||
result.response?.data?.error?.message
|
||||
);
|
||||
}
|
||||
|
||||
explainData() {
|
||||
this.$notify(
|
||||
{
|
||||
@@ -530,173 +378,6 @@ export default class GiftedDialog extends Vue {
|
||||
-1,
|
||||
);
|
||||
}
|
||||
|
||||
selectGiver(contact?: Contact) {
|
||||
if (contact) {
|
||||
this.giver = {
|
||||
did: contact.did,
|
||||
name: contact.name || contact.did,
|
||||
};
|
||||
} else {
|
||||
this.giver = {
|
||||
did: "",
|
||||
name: "Unnamed",
|
||||
};
|
||||
}
|
||||
this.currentStep = 2;
|
||||
}
|
||||
|
||||
goBackToStep1(step: string) {
|
||||
this.stepType = step;
|
||||
this.currentStep = 1;
|
||||
}
|
||||
|
||||
async loadProjects() {
|
||||
try {
|
||||
const response = await fetch(this.apiServer + "/api/v2/report/plans", {
|
||||
method: "GET",
|
||||
headers: await getHeaders(this.activeDid),
|
||||
});
|
||||
|
||||
if (response.status !== 200) {
|
||||
throw new Error("Failed to load projects");
|
||||
}
|
||||
|
||||
const results = await response.json();
|
||||
if (results.data) {
|
||||
this.projects = results.data;
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error("Error loading projects:", error);
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Error",
|
||||
text: "Failed to load projects",
|
||||
},
|
||||
3000,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
selectProject(project: PlanData) {
|
||||
this.giver = {
|
||||
did: project.handleId,
|
||||
name: project.name,
|
||||
image: project.image,
|
||||
handleId: project.handleId,
|
||||
};
|
||||
this.receiver = {
|
||||
did: this.activeDid,
|
||||
name: "You",
|
||||
};
|
||||
this.currentStep = 2;
|
||||
}
|
||||
|
||||
selectRecipient(contact?: Contact) {
|
||||
if (contact) {
|
||||
this.receiver = {
|
||||
did: contact.did,
|
||||
name: contact.name || contact.did,
|
||||
};
|
||||
} else {
|
||||
this.receiver = {
|
||||
did: "",
|
||||
name: "Unnamed",
|
||||
};
|
||||
}
|
||||
this.currentStep = 2;
|
||||
}
|
||||
|
||||
selectRecipientProject(project: PlanData) {
|
||||
this.receiver = {
|
||||
did: project.handleId,
|
||||
name: project.name,
|
||||
image: project.image,
|
||||
handleId: project.handleId,
|
||||
};
|
||||
this.currentStep = 2;
|
||||
}
|
||||
|
||||
// Computed property for the query parameters
|
||||
get giftedDetailsQuery() {
|
||||
return {
|
||||
amountInput: this.amountInput,
|
||||
description: this.description,
|
||||
giverDid: this.giverEntityType === "person" ? this.giver?.did : undefined,
|
||||
giverName: this.giver?.name,
|
||||
offerId: this.offerId,
|
||||
fulfillsProjectId:
|
||||
this.giverEntityType === "person" &&
|
||||
this.recipientEntityType === "project"
|
||||
? this.toProjectId
|
||||
: undefined,
|
||||
providerProjectId:
|
||||
this.giverEntityType === "project" &&
|
||||
this.recipientEntityType === "person"
|
||||
? this.giver?.handleId
|
||||
: this.fromProjectId,
|
||||
recipientDid: this.receiver?.did,
|
||||
recipientName: this.receiver?.name,
|
||||
unitCode: this.unitCode,
|
||||
};
|
||||
}
|
||||
|
||||
// New event handlers for component integration
|
||||
|
||||
/**
|
||||
* Handle entity selection from EntitySelectionStep
|
||||
* @param entity - The selected entity (person or project)
|
||||
*/
|
||||
handleEntitySelected(entity: {
|
||||
type: "person" | "project";
|
||||
data: Contact | PlanData;
|
||||
}) {
|
||||
if (entity.type === "person") {
|
||||
const contact = entity.data as Contact;
|
||||
if (this.stepType === "giver") {
|
||||
this.selectGiver(contact);
|
||||
} else {
|
||||
this.selectRecipient(contact);
|
||||
}
|
||||
} else {
|
||||
const project = entity.data as PlanData;
|
||||
if (this.stepType === "giver") {
|
||||
this.selectProject(project);
|
||||
} else {
|
||||
this.selectRecipientProject(project);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle edit entity request from GiftDetailsStep
|
||||
* @param entityType - 'giver' or 'recipient'
|
||||
*/
|
||||
handleEditEntity(entityType: "giver" | "recipient") {
|
||||
this.goBackToStep1(entityType);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle form submission from GiftDetailsStep
|
||||
*/
|
||||
handleSubmit() {
|
||||
this.confirm();
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle amount update from GiftDetailsStep
|
||||
*/
|
||||
handleAmountUpdate(newAmount: number) {
|
||||
console.log(
|
||||
`[GiftedDialog] handleAmountUpdate() called - oldAmount: ${this.amountInput}, newAmount: ${newAmount}`,
|
||||
);
|
||||
this.amountInput = newAmount.toString();
|
||||
console.log(
|
||||
`[GiftedDialog] handleAmountUpdate() - amountInput updated to: ${this.amountInput}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
@@ -48,12 +48,15 @@
|
||||
<span>
|
||||
{{ didInfo(visDid) }}
|
||||
<span v-if="!serverUtil.isEmptyOrHiddenDid(visDid)">
|
||||
<a :href="`/did/${visDid}`" class="text-blue-500">
|
||||
<router-link
|
||||
:to="{ path: '/did/' + encodeURIComponent(visDid) }"
|
||||
class="text-blue-500"
|
||||
>
|
||||
<font-awesome
|
||||
icon="arrow-up-right-from-square"
|
||||
class="fa-fw"
|
||||
/>
|
||||
</a>
|
||||
</router-link>
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
@@ -74,7 +77,7 @@
|
||||
If you'd like an introduction,
|
||||
<a
|
||||
class="text-blue-500"
|
||||
@click="copyToClipboard('A link to this page', windowLocation)"
|
||||
@click="copyToClipboard('A link to this page', deepLinkUrl)"
|
||||
>click here to copy this page, paste it into a message, and ask if
|
||||
they'll tell you more about the {{ roleName }}.</a
|
||||
>
|
||||
@@ -101,7 +104,7 @@ import * as R from "ramda";
|
||||
import { useClipboard } from "@vueuse/core";
|
||||
import { Contact } from "../db/tables/contacts";
|
||||
import * as serverUtil from "../libs/endorserServer";
|
||||
import { NotificationIface } from "../constants/app";
|
||||
import { APP_SERVER, NotificationIface } from "../constants/app";
|
||||
|
||||
@Component
|
||||
export default class HiddenDidDialog extends Vue {
|
||||
@@ -114,7 +117,8 @@ export default class HiddenDidDialog extends Vue {
|
||||
activeDid = "";
|
||||
allMyDids: Array<string> = [];
|
||||
canShare = false;
|
||||
windowLocation = window.location.href;
|
||||
deepLinkPathSuffix = "";
|
||||
deepLinkUrl = window.location.href; // this is changed to a deep link in the setup
|
||||
|
||||
R = R;
|
||||
serverUtil = serverUtil;
|
||||
@@ -126,17 +130,21 @@ export default class HiddenDidDialog extends Vue {
|
||||
}
|
||||
|
||||
open(
|
||||
deepLinkPathSuffix: string,
|
||||
roleName: string,
|
||||
visibleToDids: string[],
|
||||
allContacts: Array<Contact>,
|
||||
activeDid: string,
|
||||
allMyDids: Array<string>,
|
||||
) {
|
||||
this.deepLinkPathSuffix = deepLinkPathSuffix;
|
||||
this.roleName = roleName;
|
||||
this.visibleToDids = visibleToDids;
|
||||
this.allContacts = allContacts;
|
||||
this.activeDid = activeDid;
|
||||
this.allMyDids = allMyDids;
|
||||
|
||||
this.deepLinkUrl = APP_SERVER + "/deep-link/" + this.deepLinkPathSuffix;
|
||||
this.isOpen = true;
|
||||
}
|
||||
|
||||
@@ -170,11 +178,11 @@ export default class HiddenDidDialog extends Vue {
|
||||
}
|
||||
|
||||
onClickShareClaim() {
|
||||
this.copyToClipboard("A link to this page", this.windowLocation);
|
||||
this.copyToClipboard("A link to this page", this.deepLinkUrl);
|
||||
window.navigator.share({
|
||||
title: "Help Connect Me",
|
||||
text: "I'm trying to find the people who recorded this. Can you help me?",
|
||||
url: this.windowLocation,
|
||||
url: this.deepLinkUrl,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -83,10 +83,7 @@
|
||||
import { Vue, Component, Prop } from "vue-facing-decorator";
|
||||
|
||||
import { NotificationIface, USE_DEXIE_DB } from "../constants/app";
|
||||
import {
|
||||
createAndSubmitOffer,
|
||||
serverMessageForUser,
|
||||
} from "../libs/endorserServer";
|
||||
import { createAndSubmitOffer } from "../libs/endorserServer";
|
||||
import * as libsUtil from "../libs/util";
|
||||
import * as databaseUtil from "../db/databaseUtil";
|
||||
import { retrieveSettingsForActiveAccount } from "../db/index";
|
||||
@@ -250,7 +247,7 @@ export default class OfferDialog extends Vue {
|
||||
);
|
||||
|
||||
if (!result.success) {
|
||||
const errorMessage = this.getOfferCreationErrorMessage(result);
|
||||
const errorMessage = result.error;
|
||||
logger.error("Error with offer creation result:", result);
|
||||
this.$notify(
|
||||
{
|
||||
@@ -290,21 +287,6 @@ export default class OfferDialog extends Vue {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Helper functions for readability
|
||||
|
||||
/**
|
||||
* @param result direct response eg. ErrorResult or SuccessResult (potentially with embedded "data")
|
||||
* @returns best guess at an error message
|
||||
*/
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
getOfferCreationErrorMessage(result: any) {
|
||||
return (
|
||||
serverMessageForUser(result) ||
|
||||
result.error?.userMessage ||
|
||||
result.error?.error
|
||||
);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
@@ -1,114 +0,0 @@
|
||||
/** * PersonCard.vue - Individual person display component * * Extracted from
|
||||
GiftedDialog.vue to handle person entity display * with selection states and
|
||||
conflict detection. * * @author Matthew Raymer */
|
||||
<template>
|
||||
<li :class="cardClasses" @click="handleClick">
|
||||
<div class="relative w-fit mx-auto">
|
||||
<EntityIcon
|
||||
v-if="person.did"
|
||||
:contact="person"
|
||||
class="!size-[3rem] mx-auto border border-slate-300 bg-white overflow-hidden rounded-full mb-1"
|
||||
/>
|
||||
<font-awesome
|
||||
v-else
|
||||
icon="circle-question"
|
||||
class="text-slate-400 text-5xl mb-1"
|
||||
/>
|
||||
|
||||
<!-- Time icon overlay for contacts -->
|
||||
<div
|
||||
v-if="person.did && showTimeIcon"
|
||||
class="rounded-full bg-slate-400 absolute bottom-0 right-0 p-1 translate-x-1/3"
|
||||
>
|
||||
<font-awesome icon="clock" class="block text-white text-xs w-[1em]" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3 :class="nameClasses">
|
||||
{{ person.name || person.did || "Unnamed" }}
|
||||
</h3>
|
||||
</li>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { Component, Prop, Vue, Emit } from "vue-facing-decorator";
|
||||
import EntityIcon from "./EntityIcon.vue";
|
||||
import { Contact } from "../db/tables/contacts";
|
||||
|
||||
/**
|
||||
* PersonCard - Individual person display with selection capability
|
||||
*
|
||||
* Features:
|
||||
* - Person avatar using EntityIcon
|
||||
* - Selection states (selectable, conflicted, disabled)
|
||||
* - Time icon overlay for contacts
|
||||
* - Click event handling
|
||||
* - Emits click events for parent handling
|
||||
*/
|
||||
@Component({
|
||||
components: {
|
||||
EntityIcon,
|
||||
},
|
||||
})
|
||||
export default class PersonCard extends Vue {
|
||||
/** Contact data to display */
|
||||
@Prop({ required: true })
|
||||
person!: Contact;
|
||||
|
||||
/** Whether this person can be selected */
|
||||
@Prop({ default: true })
|
||||
selectable!: boolean;
|
||||
|
||||
/** Whether this person would create a conflict if selected */
|
||||
@Prop({ default: false })
|
||||
conflicted!: boolean;
|
||||
|
||||
/** Whether to show time icon overlay */
|
||||
@Prop({ default: false })
|
||||
showTimeIcon!: boolean;
|
||||
|
||||
/**
|
||||
* Computed CSS classes for the card
|
||||
*/
|
||||
get cardClasses(): string {
|
||||
if (!this.selectable || this.conflicted) {
|
||||
return "opacity-50 cursor-not-allowed";
|
||||
}
|
||||
return "cursor-pointer hover:bg-slate-50";
|
||||
}
|
||||
|
||||
/**
|
||||
* Computed CSS classes for the person name
|
||||
*/
|
||||
get nameClasses(): string {
|
||||
const baseClasses =
|
||||
"text-xs font-medium text-ellipsis whitespace-nowrap overflow-hidden";
|
||||
|
||||
if (this.conflicted) {
|
||||
return `${baseClasses} text-slate-400`;
|
||||
}
|
||||
|
||||
return baseClasses;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle card click - only emit if selectable and not conflicted
|
||||
*/
|
||||
handleClick(): void {
|
||||
if (this.selectable && !this.conflicted) {
|
||||
this.emitPersonSelected(this.person);
|
||||
}
|
||||
}
|
||||
|
||||
// Emit methods using @Emit decorator
|
||||
|
||||
@Emit("person-selected")
|
||||
emitPersonSelected(person: Contact): Contact {
|
||||
return person;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* Component-specific styles if needed */
|
||||
</style>
|
||||
@@ -1,96 +0,0 @@
|
||||
/** * ProjectCard.vue - Individual project display component * * Extracted from
|
||||
GiftedDialog.vue to handle project entity display * with selection states and
|
||||
issuer information. * * @author Matthew Raymer */
|
||||
<template>
|
||||
<li class="cursor-pointer" @click="handleClick">
|
||||
<div class="relative w-fit mx-auto">
|
||||
<ProjectIcon
|
||||
:entity-id="project.handleId"
|
||||
:icon-size="48"
|
||||
:image-url="project.image"
|
||||
class="!size-[3rem] mx-auto border border-slate-300 bg-white overflow-hidden rounded-full mb-1"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<h3
|
||||
class="text-xs font-medium text-ellipsis whitespace-nowrap overflow-hidden"
|
||||
>
|
||||
{{ project.name }}
|
||||
</h3>
|
||||
|
||||
<div class="text-xs text-slate-500 truncate">
|
||||
<font-awesome icon="user" class="fa-fw text-slate-400" />
|
||||
{{ issuerDisplayName }}
|
||||
</div>
|
||||
</li>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { Component, Prop, Vue, Emit } from "vue-facing-decorator";
|
||||
import ProjectIcon from "./ProjectIcon.vue";
|
||||
import { PlanData } from "../interfaces/records";
|
||||
import { Contact } from "../db/tables/contacts";
|
||||
import { didInfo } from "../libs/endorserServer";
|
||||
|
||||
/**
|
||||
* ProjectCard - Displays a project entity with selection capability
|
||||
*
|
||||
* Features:
|
||||
* - Shows project icon using ProjectIcon
|
||||
* - Displays project name and issuer information
|
||||
* - Handles click events for selection
|
||||
* - Shows issuer name using didInfo utility
|
||||
*/
|
||||
@Component({
|
||||
components: {
|
||||
ProjectIcon,
|
||||
},
|
||||
})
|
||||
export default class ProjectCard extends Vue {
|
||||
/** Project entity to display */
|
||||
@Prop({ required: true })
|
||||
project!: PlanData;
|
||||
|
||||
/** Active user's DID for issuer display */
|
||||
@Prop({ required: true })
|
||||
activeDid!: string;
|
||||
|
||||
/** All user's DIDs for issuer display */
|
||||
@Prop({ required: true })
|
||||
allMyDids!: string[];
|
||||
|
||||
/** All contacts for issuer display */
|
||||
@Prop({ required: true })
|
||||
allContacts!: Contact[];
|
||||
|
||||
/**
|
||||
* Computed display name for the project issuer
|
||||
*/
|
||||
get issuerDisplayName(): string {
|
||||
return didInfo(
|
||||
this.project.issuerDid,
|
||||
this.activeDid,
|
||||
this.allMyDids,
|
||||
this.allContacts,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle card click - emit project selection
|
||||
*/
|
||||
handleClick(): void {
|
||||
this.emitProjectSelected(this.project);
|
||||
}
|
||||
|
||||
// Emit methods using @Emit decorator
|
||||
|
||||
@Emit("project-selected")
|
||||
emitProjectSelected(project: PlanData): PlanData {
|
||||
return project;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* Component-specific styles if needed */
|
||||
</style>
|
||||
@@ -1,66 +0,0 @@
|
||||
/** * ShowAllCard.vue - Show All navigation card component * * Extracted from
|
||||
GiftedDialog.vue to handle "Show All" navigation * for both people and projects
|
||||
entity types. * * @author Matthew Raymer */
|
||||
<template>
|
||||
<li class="cursor-pointer">
|
||||
<router-link :to="navigationRoute" class="block text-center">
|
||||
<font-awesome icon="circle-right" class="text-blue-500 text-5xl mb-1" />
|
||||
<h3
|
||||
class="text-xs text-slate-500 font-medium italic text-ellipsis whitespace-nowrap overflow-hidden"
|
||||
>
|
||||
Show All
|
||||
</h3>
|
||||
</router-link>
|
||||
</li>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { Component, Prop, Vue } from "vue-facing-decorator";
|
||||
import { RouteLocationRaw } from "vue-router";
|
||||
|
||||
/**
|
||||
* ShowAllCard - Displays "Show All" navigation for entity grids
|
||||
*
|
||||
* Features:
|
||||
* - Provides navigation to full entity listings
|
||||
* - Supports different routes based on entity type
|
||||
* - Maintains context through query parameters
|
||||
* - Consistent visual styling with other cards
|
||||
*/
|
||||
@Component
|
||||
export default class ShowAllCard extends Vue {
|
||||
/** Type of entities being shown */
|
||||
@Prop({ required: true })
|
||||
entityType!: "people" | "projects";
|
||||
|
||||
/** Route name to navigate to */
|
||||
@Prop({ required: true })
|
||||
routeName!: string;
|
||||
|
||||
/** Query parameters to pass to the route */
|
||||
@Prop({ default: () => ({}) })
|
||||
queryParams!: Record<string, any>;
|
||||
|
||||
/**
|
||||
* Computed navigation route with query parameters
|
||||
*/
|
||||
get navigationRoute(): RouteLocationRaw {
|
||||
return {
|
||||
name: this.routeName,
|
||||
query: this.queryParams,
|
||||
};
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* Ensure router-link styling is consistent */
|
||||
a {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
a:hover .fa-circle-right {
|
||||
transform: scale(1.1);
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
</style>
|
||||
@@ -1,135 +0,0 @@
|
||||
/** * SpecialEntityCard.vue - Special entity display component * * Extracted
|
||||
from GiftedDialog.vue to handle special entities like "You" * and "Unnamed" with
|
||||
conflict detection and selection capability. * * @author Matthew Raymer */
|
||||
<template>
|
||||
<li :class="cardClasses" @click="handleClick">
|
||||
<font-awesome :icon="icon" :class="iconClasses" />
|
||||
<h3 :class="nameClasses">
|
||||
{{ label }}
|
||||
</h3>
|
||||
</li>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { Component, Prop, Vue } from "vue-facing-decorator";
|
||||
import { Emit } from "vue-facing-decorator";
|
||||
|
||||
/**
|
||||
* SpecialEntityCard - Displays special entities with selection capability
|
||||
*
|
||||
* Features:
|
||||
* - Displays special entities like "You" and "Unnamed"
|
||||
* - Shows appropriate FontAwesome icons
|
||||
* - Handles conflict states and selection
|
||||
* - Emits selection events with entity data
|
||||
* - Configurable styling based on entity type
|
||||
*/
|
||||
@Component({
|
||||
emits: ["entity-selected"],
|
||||
})
|
||||
export default class SpecialEntityCard extends Vue {
|
||||
/** Type of special entity */
|
||||
@Prop({ required: true })
|
||||
entityType!: "you" | "unnamed";
|
||||
|
||||
/** Display label for the entity */
|
||||
@Prop({ required: true })
|
||||
label!: string;
|
||||
|
||||
/** FontAwesome icon name */
|
||||
@Prop({ required: true })
|
||||
icon!: string;
|
||||
|
||||
/** Whether this entity can be selected */
|
||||
@Prop({ default: true })
|
||||
selectable!: boolean;
|
||||
|
||||
/** Whether selecting this entity would create a conflict */
|
||||
@Prop({ default: false })
|
||||
conflicted!: boolean;
|
||||
|
||||
/** Entity data to emit when selected */
|
||||
@Prop({ required: true })
|
||||
entityData!: { did?: string; name: string };
|
||||
|
||||
/**
|
||||
* Computed CSS classes for the card container
|
||||
*/
|
||||
get cardClasses(): string {
|
||||
const baseClasses = "block";
|
||||
|
||||
if (!this.selectable || this.conflicted) {
|
||||
return `${baseClasses} cursor-not-allowed opacity-50`;
|
||||
}
|
||||
|
||||
return `${baseClasses} cursor-pointer`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Computed CSS classes for the icon
|
||||
*/
|
||||
get iconClasses(): string {
|
||||
const baseClasses = "text-5xl mb-1";
|
||||
|
||||
if (this.conflicted) {
|
||||
return `${baseClasses} text-slate-400`;
|
||||
}
|
||||
|
||||
// Different colors for different entity types
|
||||
switch (this.entityType) {
|
||||
case "you":
|
||||
return `${baseClasses} text-blue-500`;
|
||||
case "unnamed":
|
||||
return `${baseClasses} text-slate-400`;
|
||||
default:
|
||||
return `${baseClasses} text-slate-400`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Computed CSS classes for the entity name/label
|
||||
*/
|
||||
get nameClasses(): string {
|
||||
const baseClasses =
|
||||
"text-xs font-medium text-ellipsis whitespace-nowrap overflow-hidden";
|
||||
|
||||
if (this.conflicted) {
|
||||
return `${baseClasses} text-slate-400`;
|
||||
}
|
||||
|
||||
// Different colors for different entity types
|
||||
switch (this.entityType) {
|
||||
case "you":
|
||||
return `${baseClasses} text-blue-500`;
|
||||
case "unnamed":
|
||||
return `${baseClasses} text-slate-500 italic`;
|
||||
default:
|
||||
return `${baseClasses} text-slate-500`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle card click - only emit if selectable and not conflicted
|
||||
*/
|
||||
handleClick(): void {
|
||||
if (this.selectable && !this.conflicted) {
|
||||
this.emitEntitySelected({
|
||||
type: "special",
|
||||
entityType: this.entityType,
|
||||
data: this.entityData,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Emit methods using @Emit decorator
|
||||
|
||||
@Emit("entity-selected")
|
||||
emitEntitySelected(data: any): any {
|
||||
return data;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* Component-specific styles if needed */
|
||||
</style>
|
||||
@@ -38,14 +38,13 @@ export default class TopMessage extends Vue {
|
||||
settings.apiServer !== AppString.PROD_ENDORSER_API_SERVER
|
||||
) {
|
||||
const didPrefix = settings.activeDid?.slice(11, 15);
|
||||
this.message = "You're linked to a non-prod server, user " + didPrefix;
|
||||
this.message = "You're not using prod, user " + didPrefix;
|
||||
} else if (
|
||||
settings.warnIfProdServer &&
|
||||
settings.apiServer === AppString.PROD_ENDORSER_API_SERVER
|
||||
) {
|
||||
const didPrefix = settings.activeDid?.slice(11, 15);
|
||||
this.message =
|
||||
"You're linked to the production server, user " + didPrefix;
|
||||
this.message = "You are using prod, user " + didPrefix;
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
this.$notify(
|
||||
|
||||
@@ -219,9 +219,9 @@ export async function logConsoleAndDb(
|
||||
isError = false,
|
||||
): Promise<void> {
|
||||
if (isError) {
|
||||
logger.error(`${new Date().toISOString()} ${message}`);
|
||||
logger.error(`${new Date().toISOString()}`, message);
|
||||
} else {
|
||||
logger.log(`${new Date().toISOString()} ${message}`);
|
||||
logger.log(`${new Date().toISOString()}`, message);
|
||||
}
|
||||
await logToDb(message);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
import { AxiosResponse } from "axios";
|
||||
import { GiverReceiverInputInfo } from "../libs/util";
|
||||
import { ErrorResult, ResultWithType } from "./common";
|
||||
|
||||
export interface GiverOutputInfo {
|
||||
action: string;
|
||||
@@ -47,12 +45,3 @@ export interface ProviderInfo {
|
||||
*/
|
||||
linkConfirmed: boolean;
|
||||
}
|
||||
|
||||
// Type for createAndSubmitClaim result
|
||||
export type CreateAndSubmitClaimResult = SuccessResult | ErrorResult;
|
||||
|
||||
// Update SuccessResult to use ClaimResult
|
||||
export interface SuccessResult extends ResultWithType {
|
||||
type: "success";
|
||||
response: AxiosResponse<ClaimResult>;
|
||||
}
|
||||
|
||||
@@ -15,10 +15,6 @@ export interface GenericCredWrapper<T extends GenericVerifiableCredential> {
|
||||
publicUrls?: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface ResultWithType {
|
||||
type: string;
|
||||
}
|
||||
|
||||
export interface ErrorResponse {
|
||||
error?: {
|
||||
message?: string;
|
||||
@@ -30,11 +26,6 @@ export interface InternalError {
|
||||
userMessage?: string;
|
||||
}
|
||||
|
||||
export interface ErrorResult extends ResultWithType {
|
||||
type: "error";
|
||||
error: InternalError;
|
||||
}
|
||||
|
||||
export interface KeyMeta {
|
||||
did: string;
|
||||
publicKeyHex: string;
|
||||
|
||||
@@ -29,18 +29,17 @@ import { z } from "zod";
|
||||
|
||||
// Add a union type of all valid route paths
|
||||
export const VALID_DEEP_LINK_ROUTES = [
|
||||
"user-profile",
|
||||
"project-details",
|
||||
"onboard-meeting-setup",
|
||||
"invite-one-accept",
|
||||
"contact-import",
|
||||
"confirm-gift",
|
||||
// note that similar lists are below in deepLinkSchemas and in src/services/deepLinks.ts
|
||||
"claim",
|
||||
"claim-cert",
|
||||
"claim-add-raw",
|
||||
"contact-edit",
|
||||
"contacts",
|
||||
"claim-cert",
|
||||
"confirm-gift",
|
||||
"contact-import",
|
||||
"did",
|
||||
"invite-one-accept",
|
||||
"onboard-meeting-setup",
|
||||
"project",
|
||||
"user-profile",
|
||||
] as const;
|
||||
|
||||
// Create a type from the array
|
||||
@@ -58,44 +57,39 @@ export const routeSchema = z.enum(VALID_DEEP_LINK_ROUTES);
|
||||
|
||||
// Parameter validation schemas for each route type
|
||||
export const deepLinkSchemas = {
|
||||
"user-profile": z.object({
|
||||
id: z.string(),
|
||||
}),
|
||||
"project-details": z.object({
|
||||
id: z.string(),
|
||||
}),
|
||||
"onboard-meeting-setup": z.object({
|
||||
id: z.string(),
|
||||
}),
|
||||
"invite-one-accept": z.object({
|
||||
id: z.string(),
|
||||
}),
|
||||
"contact-import": z.object({
|
||||
jwt: z.string(),
|
||||
}),
|
||||
"confirm-gift": z.object({
|
||||
id: z.string(),
|
||||
}),
|
||||
// note that similar lists are above in VALID_DEEP_LINK_ROUTES and in src/services/deepLinks.ts
|
||||
claim: z.object({
|
||||
id: z.string(),
|
||||
}),
|
||||
"claim-cert": z.object({
|
||||
id: z.string(),
|
||||
}),
|
||||
"claim-add-raw": z.object({
|
||||
id: z.string(),
|
||||
claim: z.string().optional(),
|
||||
claimJwtId: z.string().optional(),
|
||||
}),
|
||||
"contact-edit": z.object({
|
||||
did: z.string(),
|
||||
"claim-cert": z.object({
|
||||
id: z.string(),
|
||||
}),
|
||||
contacts: z.object({
|
||||
contacts: z.string(), // JSON string of contacts array
|
||||
"confirm-gift": z.object({
|
||||
id: z.string(),
|
||||
}),
|
||||
"contact-import": z.object({
|
||||
jwt: z.string(),
|
||||
}),
|
||||
did: z.object({
|
||||
did: z.string(),
|
||||
}),
|
||||
"invite-one-accept": z.object({
|
||||
jwt: z.string(),
|
||||
}),
|
||||
"onboard-meeting-setup": z.object({
|
||||
id: z.string(),
|
||||
}),
|
||||
project: z.object({
|
||||
id: z.string(),
|
||||
}),
|
||||
"user-profile": z.object({
|
||||
id: z.string(),
|
||||
}),
|
||||
};
|
||||
|
||||
export type DeepLinkParams = {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
export type {
|
||||
// From common.ts
|
||||
CreateAndSubmitClaimResult,
|
||||
GenericCredWrapper,
|
||||
GenericVerifiableCredential,
|
||||
KeyMeta,
|
||||
@@ -18,11 +19,6 @@ export type {
|
||||
RegisterActionClaim,
|
||||
} from "./claims";
|
||||
|
||||
export type {
|
||||
// From claims-result.ts
|
||||
CreateAndSubmitClaimResult,
|
||||
} from "./claims-result";
|
||||
|
||||
export type {
|
||||
// From records.ts
|
||||
PlanSummaryRecord,
|
||||
|
||||
@@ -979,7 +979,7 @@ export const createAndSubmitConfirmation = async (
|
||||
handleId: string | undefined,
|
||||
apiServer: string,
|
||||
axios: Axios,
|
||||
) => {
|
||||
): Promise<CreateAndSubmitClaimResult> => {
|
||||
const goodClaim = removeSchemaContext(
|
||||
removeVisibleToDids(
|
||||
addLastClaimOrHandleAsIdIfMissing(claim, lastClaimId, handleId),
|
||||
@@ -1074,7 +1074,8 @@ export async function generateEndorserJwtUrlForAccount(
|
||||
|
||||
const vcJwt = await createEndorserJwtForDid(account.did, contactInfo);
|
||||
|
||||
const viewPrefix = APP_SERVER + CONTACT_IMPORT_CONFIRM_URL_PATH_TIME_SAFARI;
|
||||
const viewPrefix =
|
||||
APP_SERVER + "/deep-link" + CONTACT_IMPORT_CONFIRM_URL_PATH_TIME_SAFARI;
|
||||
return viewPrefix + vcJwt;
|
||||
}
|
||||
|
||||
|
||||
@@ -29,7 +29,6 @@ import {
|
||||
faCircleCheck,
|
||||
faCircleInfo,
|
||||
faCircleQuestion,
|
||||
faCircleRight,
|
||||
faCircleUser,
|
||||
faClock,
|
||||
faCoins,
|
||||
@@ -61,7 +60,6 @@ import {
|
||||
faLightbulb,
|
||||
faLink,
|
||||
faLocationDot,
|
||||
faLock,
|
||||
faLongArrowAltLeft,
|
||||
faLongArrowAltRight,
|
||||
faMagnifyingGlass,
|
||||
@@ -81,7 +79,6 @@ import {
|
||||
faSquareCaretDown,
|
||||
faSquareCaretUp,
|
||||
faSquarePlus,
|
||||
faThumbtack,
|
||||
faTrashCan,
|
||||
faTriangleExclamation,
|
||||
faUser,
|
||||
@@ -114,7 +111,6 @@ library.add(
|
||||
faCircleCheck,
|
||||
faCircleInfo,
|
||||
faCircleQuestion,
|
||||
faCircleRight,
|
||||
faCircleUser,
|
||||
faClock,
|
||||
faCoins,
|
||||
@@ -146,7 +142,6 @@ library.add(
|
||||
faLightbulb,
|
||||
faLink,
|
||||
faLocationDot,
|
||||
faLock,
|
||||
faLongArrowAltLeft,
|
||||
faLongArrowAltRight,
|
||||
faMagnifyingGlass,
|
||||
@@ -166,7 +161,6 @@ library.add(
|
||||
faSquareCaretDown,
|
||||
faSquareCaretUp,
|
||||
faSquarePlus,
|
||||
faThumbtack,
|
||||
faTrashCan,
|
||||
faTriangleExclamation,
|
||||
faUser,
|
||||
|
||||
@@ -49,8 +49,6 @@ import { insertDidSpecificSettings, parseJsonField } from "../db/databaseUtil";
|
||||
export interface GiverReceiverInputInfo {
|
||||
did?: string;
|
||||
name?: string;
|
||||
image?: string;
|
||||
handleId?: string;
|
||||
}
|
||||
|
||||
export enum OnboardPage {
|
||||
@@ -884,6 +882,71 @@ export const contactToCsvLine = (contact: Contact): string => {
|
||||
return fields.join(",");
|
||||
};
|
||||
|
||||
/**
|
||||
* Parses a CSV line into a Contact object. See contactToCsvLine for the format.
|
||||
* @param lineRaw - The CSV line to parse
|
||||
* @returns A Contact object
|
||||
*/
|
||||
export const csvLineToContact = (lineRaw: string): Contact => {
|
||||
// Note that Endorser Mobile puts name first, then did, etc.
|
||||
let line = lineRaw.trim();
|
||||
let did, publicKeyInput, seesMe, registered;
|
||||
let name;
|
||||
let commaPos1 = -1;
|
||||
if (line.startsWith('"')) {
|
||||
let doubleDoubleQuotePos = line.lastIndexOf('""') + 2;
|
||||
if (doubleDoubleQuotePos === -1) {
|
||||
doubleDoubleQuotePos = 1;
|
||||
}
|
||||
const quote2Pos = line.indexOf('"', doubleDoubleQuotePos);
|
||||
if (quote2Pos > -1) {
|
||||
commaPos1 = line.indexOf(",", quote2Pos);
|
||||
name = line.substring(1, quote2Pos).trim();
|
||||
name = name.replace(/""/g, '"');
|
||||
} else {
|
||||
// something is weird with one " to start, so ignore it and start after "
|
||||
line = line.substring(1);
|
||||
commaPos1 = line.indexOf(",");
|
||||
name = line.substring(0, commaPos1).trim();
|
||||
}
|
||||
} else {
|
||||
commaPos1 = line.indexOf(",");
|
||||
name = line.substring(0, commaPos1).trim();
|
||||
}
|
||||
if (commaPos1 > -1) {
|
||||
did = line.substring(commaPos1 + 1).trim();
|
||||
const commaPos2 = line.indexOf(",", commaPos1 + 1);
|
||||
if (commaPos2 > -1) {
|
||||
did = line.substring(commaPos1 + 1, commaPos2).trim();
|
||||
publicKeyInput = line.substring(commaPos2 + 1).trim();
|
||||
const commaPos3 = line.indexOf(",", commaPos2 + 1);
|
||||
if (commaPos3 > -1) {
|
||||
publicKeyInput = line.substring(commaPos2 + 1, commaPos3).trim();
|
||||
seesMe = line.substring(commaPos3 + 1).trim() == "true";
|
||||
const commaPos4 = line.indexOf(",", commaPos3 + 1);
|
||||
if (commaPos4 > -1) {
|
||||
seesMe = line.substring(commaPos3 + 1, commaPos4).trim() == "true";
|
||||
registered = line.substring(commaPos4 + 1).trim() == "true";
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// help with potential mistakes while this sharing requires copy-and-paste
|
||||
let publicKeyBase64 = publicKeyInput;
|
||||
if (publicKeyBase64 && /^[0-9A-Fa-f]{66}$/i.test(publicKeyBase64)) {
|
||||
// it must be all hex (compressed public key), so convert
|
||||
publicKeyBase64 = Buffer.from(publicKeyBase64, "hex").toString("base64");
|
||||
}
|
||||
const newContact: Contact = {
|
||||
did: did || "",
|
||||
name,
|
||||
publicKeyBase64,
|
||||
seesMe,
|
||||
registered,
|
||||
};
|
||||
return newContact;
|
||||
};
|
||||
|
||||
/**
|
||||
* Interface for the JSON export format of database tables
|
||||
*/
|
||||
|
||||
@@ -34,8 +34,7 @@ import router from "./router";
|
||||
import { handleApiError } from "./services/api";
|
||||
import { AxiosError } from "axios";
|
||||
import { DeepLinkHandler } from "./services/deepLinks";
|
||||
import { logConsoleAndDb } from "./db/databaseUtil";
|
||||
import { logger } from "./utils/logger";
|
||||
import { logger, safeStringify } from "./utils/logger";
|
||||
|
||||
logger.log("[Capacitor] Starting initialization");
|
||||
logger.log("[Capacitor] Platform:", process.env.VITE_PLATFORM);
|
||||
@@ -72,10 +71,10 @@ const handleDeepLink = async (data: { url: string }) => {
|
||||
await router.isReady();
|
||||
await deepLinkHandler.handleDeepLink(data.url);
|
||||
} catch (error) {
|
||||
logConsoleAndDb("[DeepLink] Error handling deep link: " + error, true);
|
||||
logger.error("[DeepLink] Error handling deep link: ", error);
|
||||
handleApiError(
|
||||
{
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
message: error instanceof Error ? error.message : safeStringify(error),
|
||||
} as AxiosError,
|
||||
"deep-link",
|
||||
);
|
||||
|
||||
@@ -83,6 +83,11 @@ const routes: Array<RouteRecordRaw> = [
|
||||
name: "discover",
|
||||
component: () => import("../views/DiscoverView.vue"),
|
||||
},
|
||||
{
|
||||
path: "/deep-link/:path*",
|
||||
name: "deep-link",
|
||||
component: () => import("../views/DeepLinkRedirectView.vue"),
|
||||
},
|
||||
{
|
||||
path: "/gifted-details",
|
||||
name: "gifted-details",
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
*/
|
||||
|
||||
import { AxiosError } from "axios";
|
||||
import { logger } from "../utils/logger";
|
||||
import { logger, safeStringify } from "../utils/logger";
|
||||
|
||||
/**
|
||||
* Handles API errors with platform-specific logging and error processing.
|
||||
@@ -37,7 +37,8 @@ import { logger } from "../utils/logger";
|
||||
*/
|
||||
export const handleApiError = (error: AxiosError, endpoint: string) => {
|
||||
if (process.env.VITE_PLATFORM === "capacitor") {
|
||||
logger.error(`[Capacitor API Error] ${endpoint}:`, {
|
||||
const endpointStr = safeStringify(endpoint); // we've seen this as an object in deep links
|
||||
logger.error(`[Capacitor API Error] ${endpointStr}:`, {
|
||||
message: error.message,
|
||||
status: error.response?.status,
|
||||
data: error.response?.data,
|
||||
|
||||
@@ -27,18 +27,16 @@
|
||||
* timesafari://<route>[/<param>][?queryParam1=value1&queryParam2=value2]
|
||||
*
|
||||
* Supported Routes:
|
||||
* - user-profile: View user profile
|
||||
* - project-details: View project details
|
||||
* - onboard-meeting-setup: Setup onboarding meeting
|
||||
* - invite-one-accept: Accept invitation
|
||||
* - contact-import: Import contacts
|
||||
* - confirm-gift: Confirm gift
|
||||
* - claim: View claim
|
||||
* - claim-cert: View claim certificate
|
||||
* - claim-add-raw: Add raw claim
|
||||
* - contact-edit: Edit contact
|
||||
* - contacts: View contacts
|
||||
* - claim-cert: View claim certificate
|
||||
* - confirm-gift
|
||||
* - contact-import: Import contacts
|
||||
* - did: View DID
|
||||
* - invite-one-accept: Accept invitation
|
||||
* - onboard-meeting-members
|
||||
* - project: View project details
|
||||
* - user-profile: View user profile
|
||||
*
|
||||
* @example
|
||||
* const handler = new DeepLinkHandler(router);
|
||||
@@ -81,18 +79,17 @@ export class DeepLinkHandler {
|
||||
string,
|
||||
{ name: string; paramKey?: string }
|
||||
> = {
|
||||
"user-profile": { name: "user-profile" },
|
||||
"project-details": { name: "project-details" },
|
||||
"onboard-meeting-setup": { name: "onboard-meeting-setup" },
|
||||
"invite-one-accept": { name: "invite-one-accept" },
|
||||
"contact-import": { name: "contact-import" },
|
||||
"confirm-gift": { name: "confirm-gift" },
|
||||
// note that similar lists are in src/interfaces/deepLinks.ts
|
||||
claim: { name: "claim" },
|
||||
"claim-cert": { name: "claim-cert" },
|
||||
"claim-add-raw": { name: "claim-add-raw" },
|
||||
"contact-edit": { name: "contact-edit", paramKey: "did" },
|
||||
contacts: { name: "contacts" },
|
||||
"claim-cert": { name: "claim-cert" },
|
||||
"confirm-gift": { name: "confirm-gift" },
|
||||
"contact-import": { name: "contact-import", paramKey: "jwt" },
|
||||
did: { name: "did", paramKey: "did" },
|
||||
"invite-one-accept": { name: "invite-one-accept", paramKey: "jwt" },
|
||||
"onboard-meeting-members": { name: "onboard-meeting-members" },
|
||||
project: { name: "project" },
|
||||
"user-profile": { name: "user-profile" },
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -101,7 +98,7 @@ export class DeepLinkHandler {
|
||||
*
|
||||
* @param url - The deep link URL to parse (format: scheme://path[?query])
|
||||
* @throws {DeepLinkError} If URL format is invalid
|
||||
* @returns Parsed URL components (path, params, query)
|
||||
* @returns Parsed URL components (path: string, params: {KEY: string}, query: {KEY: string})
|
||||
*/
|
||||
private parseDeepLink(url: string) {
|
||||
const parts = url.split("://");
|
||||
@@ -117,7 +114,16 @@ export class DeepLinkHandler {
|
||||
});
|
||||
|
||||
const [path, queryString] = parts[1].split("?");
|
||||
const [routePath, param] = path.split("/");
|
||||
const [routePath, ...pathParams] = path.split("/");
|
||||
// logger.info(
|
||||
// "[DeepLink] Debug:",
|
||||
// "Route Path:",
|
||||
// routePath,
|
||||
// "Path Params:",
|
||||
// pathParams,
|
||||
// "Query String:",
|
||||
// queryString,
|
||||
// );
|
||||
|
||||
// Validate route exists before proceeding
|
||||
if (!this.ROUTE_MAP[routePath]) {
|
||||
@@ -136,45 +142,14 @@ export class DeepLinkHandler {
|
||||
}
|
||||
|
||||
const params: Record<string, string> = {};
|
||||
if (param) {
|
||||
if (pathParams) {
|
||||
// Now we know routePath exists in ROUTE_MAP
|
||||
const routeConfig = this.ROUTE_MAP[routePath];
|
||||
params[routeConfig.paramKey ?? "id"] = param;
|
||||
params[routeConfig.paramKey ?? "id"] = pathParams.join("/");
|
||||
}
|
||||
return { path: routePath, params, query };
|
||||
}
|
||||
|
||||
/**
|
||||
* Processes incoming deep links and routes them appropriately.
|
||||
* Handles validation, error handling, and routing to the correct view.
|
||||
*
|
||||
* @param url - The deep link URL to process
|
||||
* @throws {DeepLinkError} If URL processing fails
|
||||
*/
|
||||
async handleDeepLink(url: string): Promise<void> {
|
||||
try {
|
||||
logConsoleAndDb("[DeepLink] Processing URL: " + url, false);
|
||||
const { path, params, query } = this.parseDeepLink(url);
|
||||
// Ensure params is always a Record<string,string> by converting undefined to empty string
|
||||
const sanitizedParams = Object.fromEntries(
|
||||
Object.entries(params).map(([key, value]) => [key, value ?? ""]),
|
||||
);
|
||||
await this.validateAndRoute(path, sanitizedParams, query);
|
||||
} catch (error) {
|
||||
const deepLinkError = error as DeepLinkError;
|
||||
logConsoleAndDb(
|
||||
`[DeepLink] Error (${deepLinkError.code}): ${deepLinkError.message}`,
|
||||
true,
|
||||
);
|
||||
|
||||
throw {
|
||||
code: deepLinkError.code || "UNKNOWN_ERROR",
|
||||
message: deepLinkError.message,
|
||||
details: deepLinkError.details,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Routes the deep link to appropriate view with validated parameters.
|
||||
* Validates route and parameters using Zod schemas before routing.
|
||||
@@ -245,6 +220,39 @@ export class DeepLinkHandler {
|
||||
code: "INVALID_PARAMETERS",
|
||||
message: (error as Error).message,
|
||||
details: error,
|
||||
params: params,
|
||||
query: query,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Processes incoming deep links and routes them appropriately.
|
||||
* Handles validation, error handling, and routing to the correct view.
|
||||
*
|
||||
* @param url - The deep link URL to process
|
||||
* @throws {DeepLinkError} If URL processing fails
|
||||
*/
|
||||
async handleDeepLink(url: string): Promise<void> {
|
||||
try {
|
||||
logConsoleAndDb("[DeepLink] Processing URL: " + url, false);
|
||||
const { path, params, query } = this.parseDeepLink(url);
|
||||
// Ensure params is always a Record<string,string> by converting undefined to empty string
|
||||
const sanitizedParams = Object.fromEntries(
|
||||
Object.entries(params).map(([key, value]) => [key, value ?? ""]),
|
||||
);
|
||||
await this.validateAndRoute(path, sanitizedParams, query);
|
||||
} catch (error) {
|
||||
const deepLinkError = error as DeepLinkError;
|
||||
logConsoleAndDb(
|
||||
`[DeepLink] Error (${deepLinkError.code}): ${deepLinkError.message}`,
|
||||
true,
|
||||
);
|
||||
|
||||
throw {
|
||||
code: deepLinkError.code || "UNKNOWN_ERROR",
|
||||
message: deepLinkError.message,
|
||||
details: deepLinkError.details,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
CameraSource,
|
||||
CameraDirection,
|
||||
} from "@capacitor/camera";
|
||||
import { Capacitor } from "@capacitor/core";
|
||||
import { Share } from "@capacitor/share";
|
||||
import {
|
||||
SQLiteConnection,
|
||||
@@ -247,7 +248,7 @@ export class CapacitorPlatformService implements PlatformService {
|
||||
hasFileSystem: true,
|
||||
hasCamera: true,
|
||||
isMobile: true,
|
||||
isIOS: /iPad|iPhone|iPod/.test(navigator.userAgent),
|
||||
isIOS: Capacitor.getPlatform() === "ios",
|
||||
hasFileDownload: false,
|
||||
needsFileHandlingInstructions: true,
|
||||
isNativeApp: true,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { logToDb } from "../db/databaseUtil";
|
||||
|
||||
function safeStringify(obj: unknown) {
|
||||
export function safeStringify(obj: unknown) {
|
||||
const seen = new WeakSet();
|
||||
|
||||
return JSON.stringify(obj, (_key, value) => {
|
||||
@@ -67,8 +67,9 @@ export const logger = {
|
||||
// Errors will always be logged
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(message, ...args);
|
||||
const argsString = args.length > 0 ? " - " + safeStringify(args) : "";
|
||||
logToDb(message + argsString);
|
||||
const messageString = safeStringify(message);
|
||||
const argsString = args.length > 0 ? safeStringify(args) : "";
|
||||
logToDb(messageString + argsString);
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -198,7 +198,7 @@ export default class ClaimAddRawView extends Vue {
|
||||
this.apiServer,
|
||||
this.axios,
|
||||
);
|
||||
if (result.type === "success") {
|
||||
if (result.success) {
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
|
||||
@@ -46,23 +46,35 @@
|
||||
</h2>
|
||||
<div class="flex justify-center w-full">
|
||||
<router-link
|
||||
v-if="veriClaim.id"
|
||||
:to="'/claim-cert/' + encodeURIComponent(veriClaim.id)"
|
||||
class="text-blue-500 mt-2"
|
||||
title="Printable Certificate"
|
||||
title="View Printable Certificate"
|
||||
>
|
||||
<font-awesome
|
||||
icon="square"
|
||||
class="text-white bg-yellow-500 p-1"
|
||||
/>
|
||||
</router-link>
|
||||
<button
|
||||
v-if="veriClaim.id"
|
||||
class="text-blue-500 ml-2 mt-2"
|
||||
title="Copy Printable Certificate Link"
|
||||
@click="
|
||||
copyToClipboard(
|
||||
'A link to the certificate page',
|
||||
`${APP_SERVER}/deep-link/claim-cert/${veriClaim.id}`,
|
||||
)
|
||||
"
|
||||
>
|
||||
<font-awesome icon="link" class="text-yellow-500 p-1" />
|
||||
</button>
|
||||
</div>
|
||||
<!-- show link icon to copy this URL to the clipboard -->
|
||||
<div class="flex justify-end w-full">
|
||||
<button
|
||||
title="Copy Link"
|
||||
@click="
|
||||
copyToClipboard('A link to this page', window.location.href)
|
||||
"
|
||||
@click="copyToClipboard('A link to this page', windowDeepLink)"
|
||||
>
|
||||
<font-awesome icon="link" class="text-slate-500" />
|
||||
</button>
|
||||
@@ -292,12 +304,17 @@
|
||||
<div class="text-sm">
|
||||
{{ didInfo(confirmerId) }}
|
||||
<span v-if="!serverUtil.isEmptyOrHiddenDid(confirmerId)">
|
||||
<a :href="`/did/${confirmerId}`" class="text-blue-500">
|
||||
<router-link
|
||||
:to="{
|
||||
path: '/did/' + encodeURIComponent(confirmerId),
|
||||
}"
|
||||
class="text-blue-500"
|
||||
>
|
||||
<font-awesome
|
||||
icon="arrow-up-right-from-square"
|
||||
class="fa-fw"
|
||||
/>
|
||||
</a>
|
||||
</router-link>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -329,12 +346,17 @@
|
||||
<div class="text-sm">
|
||||
{{ didInfo(confsVisibleTo) }}
|
||||
<span v-if="!serverUtil.isEmptyOrHiddenDid(confsVisibleTo)">
|
||||
<a :href="`/did/${confsVisibleTo}`" class="text-blue-500">
|
||||
<router-link
|
||||
:to="{
|
||||
path: '/did/' + encodeURIComponent(confsVisibleTo),
|
||||
}"
|
||||
class="text-blue-500"
|
||||
>
|
||||
<font-awesome
|
||||
icon="arrow-up-right-from-square"
|
||||
class="fa-fw"
|
||||
/>
|
||||
</a>
|
||||
</router-link>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -394,7 +416,7 @@
|
||||
contacts can see more details:
|
||||
<a
|
||||
class="text-blue-500"
|
||||
@click="copyToClipboard('A link to this page', windowLocation)"
|
||||
@click="copyToClipboard('A link to this page', windowDeepLink)"
|
||||
>click to copy this page info</a
|
||||
>
|
||||
and see if they can make an introduction. Someone is connected to
|
||||
@@ -417,7 +439,7 @@
|
||||
If you'd like an introduction,
|
||||
<a
|
||||
class="text-blue-500"
|
||||
@click="copyToClipboard('A link to this page', windowLocation)"
|
||||
@click="copyToClipboard('A link to this page', windowDeepLink)"
|
||||
>share this page with them and ask if they'll tell you more about
|
||||
about the participants.</a
|
||||
>
|
||||
@@ -443,12 +465,17 @@
|
||||
<span>
|
||||
{{ didInfo(visDid) }}
|
||||
<span v-if="!serverUtil.isEmptyOrHiddenDid(visDid)">
|
||||
<a :href="`/did/${visDid}`" class="text-blue-500">
|
||||
<router-link
|
||||
:to="{
|
||||
path: '/did/' + encodeURIComponent(visDid),
|
||||
}"
|
||||
class="text-blue-500"
|
||||
>
|
||||
<font-awesome
|
||||
icon="arrow-up-right-from-square"
|
||||
class="fa-fw"
|
||||
/>
|
||||
</a>
|
||||
</router-link>
|
||||
</span>
|
||||
<span v-if="veriClaim.publicUrls?.[visDid]"
|
||||
>, found at <a
|
||||
@@ -530,7 +557,7 @@ import { useClipboard } from "@vueuse/core";
|
||||
import { GenericVerifiableCredential } from "../interfaces";
|
||||
import GiftedDialog from "../components/GiftedDialog.vue";
|
||||
import QuickNav from "../components/QuickNav.vue";
|
||||
import { NotificationIface, USE_DEXIE_DB } from "../constants/app";
|
||||
import { APP_SERVER, NotificationIface, USE_DEXIE_DB } from "../constants/app";
|
||||
import * as databaseUtil from "../db/databaseUtil";
|
||||
import { db } from "../db/index";
|
||||
import { logConsoleAndDb } from "../db/databaseUtil";
|
||||
@@ -577,8 +604,9 @@ export default class ClaimView extends Vue {
|
||||
veriClaim = serverUtil.BLANK_GENERIC_SERVER_RECORD;
|
||||
veriClaimDump = "";
|
||||
veriClaimDidsVisible: { [key: string]: string[] } = {};
|
||||
windowLocation = window.location.href;
|
||||
windowDeepLink = window.location.href; // changed in the setup for deep linking
|
||||
|
||||
APP_SERVER = APP_SERVER;
|
||||
R = R;
|
||||
yaml = yaml;
|
||||
libsUtil = libsUtil;
|
||||
@@ -655,6 +683,7 @@ export default class ClaimView extends Vue {
|
||||
5000,
|
||||
);
|
||||
}
|
||||
this.windowDeepLink = `${APP_SERVER}/deep-link/claim/${claimId}`;
|
||||
|
||||
this.canShare = !!navigator.share;
|
||||
}
|
||||
@@ -925,7 +954,7 @@ export default class ClaimView extends Vue {
|
||||
this.apiServer,
|
||||
this.axios,
|
||||
);
|
||||
if (result.type === "success") {
|
||||
if (result.success) {
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
@@ -990,11 +1019,11 @@ export default class ClaimView extends Vue {
|
||||
}
|
||||
|
||||
onClickShareClaim() {
|
||||
this.copyToClipboard("A link to this page", this.windowLocation);
|
||||
this.copyToClipboard("A link to this page", this.windowDeepLink);
|
||||
window.navigator.share({
|
||||
title: "Help Connect Me",
|
||||
text: "I'm trying to find the people who recorded this. Can you help me?",
|
||||
url: this.windowLocation,
|
||||
url: this.windowDeepLink,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -407,14 +407,14 @@
|
||||
</a>
|
||||
</div>
|
||||
<div class="mt-2 ml-2">
|
||||
<a
|
||||
<router-link
|
||||
v-if="isRegistered"
|
||||
class="text-blue-500 cursor-pointer"
|
||||
:href="urlForNewGive"
|
||||
:to="urlForNewGive"
|
||||
>
|
||||
<font-awesome icon="file-lines" />
|
||||
Record a Give Similar to the Original
|
||||
</a>
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -436,7 +436,7 @@ import { Component, Vue } from "vue-facing-decorator";
|
||||
import { useClipboard } from "@vueuse/core";
|
||||
import { RouteLocationNormalizedLoaded, Router } from "vue-router";
|
||||
import QuickNav from "../components/QuickNav.vue";
|
||||
import { NotificationIface, USE_DEXIE_DB } from "../constants/app";
|
||||
import { APP_SERVER, NotificationIface, USE_DEXIE_DB } from "../constants/app";
|
||||
import { db, retrieveSettingsForActiveAccount } from "../db/index";
|
||||
import { Contact } from "../db/tables/contacts";
|
||||
import * as databaseUtil from "../db/databaseUtil";
|
||||
@@ -494,7 +494,7 @@ export default class ConfirmGiftView extends Vue {
|
||||
veriClaim = serverUtil.BLANK_GENERIC_SERVER_RECORD;
|
||||
veriClaimDump = "";
|
||||
veriClaimDidsVisible: { [key: string]: string[] } = {};
|
||||
windowLocation = window.location.href;
|
||||
windowLocation = window.location.href; // this is changed to a deep link in the setup
|
||||
|
||||
R = R;
|
||||
yaml = yaml;
|
||||
@@ -566,6 +566,9 @@ export default class ConfirmGiftView extends Vue {
|
||||
}
|
||||
|
||||
const claimId = decodeURIComponent(pathParam);
|
||||
|
||||
this.windowLocation = APP_SERVER + "/deep-link/confirm-gift/" + claimId;
|
||||
|
||||
await this.loadClaim(claimId, this.activeDid);
|
||||
}
|
||||
|
||||
@@ -676,12 +679,12 @@ export default class ConfirmGiftView extends Vue {
|
||||
/**
|
||||
* Add participant (giver/recipient) name & URL info
|
||||
*/
|
||||
this.giverName = this.didInfo(this.giveDetails?.agentDid);
|
||||
if (this.giveDetails?.agentDid) {
|
||||
this.giverName = this.didInfo(this.giveDetails.agentDid);
|
||||
this.urlForNewGive += `&giverDid=${encodeURIComponent(this.giveDetails.agentDid)}&giverName=${encodeURIComponent(this.giverName)}`;
|
||||
}
|
||||
this.recipientName = this.didInfo(this.giveDetails?.recipientDid);
|
||||
if (this.giveDetails?.recipientDid) {
|
||||
this.recipientName = this.didInfo(this.giveDetails.recipientDid);
|
||||
this.urlForNewGive += `&recipientDid=${encodeURIComponent(this.giveDetails.recipientDid)}&recipientName=${encodeURIComponent(this.recipientName)}`;
|
||||
}
|
||||
|
||||
@@ -831,7 +834,7 @@ export default class ConfirmGiftView extends Vue {
|
||||
this.apiServer,
|
||||
this.axios,
|
||||
);
|
||||
if (result.type === "success") {
|
||||
if (result.success) {
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
class="text-lg text-center px-2 py-1 absolute -left-2 -top-1"
|
||||
><font-awesome icon="chevron-left" class="fa-fw"></font-awesome>
|
||||
</router-link>
|
||||
{{ stepType === "giver" ? "Given by..." : "Given to..." }}
|
||||
Given by...
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
@@ -31,7 +31,7 @@
|
||||
<button
|
||||
type="button"
|
||||
class="block w-full text-center text-sm uppercase bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-3 py-1.5 rounded-md"
|
||||
@click="openDialog('Unnamed')"
|
||||
@click="openDialog()"
|
||||
>
|
||||
<font-awesome icon="gift" class="fa-fw"></font-awesome>
|
||||
</button>
|
||||
@@ -65,13 +65,7 @@
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<GiftedDialog
|
||||
ref="customDialog"
|
||||
:from-project-id="fromProjectId"
|
||||
:to-project-id="toProjectId"
|
||||
:show-projects="showProjects"
|
||||
:is-from-project-view="isFromProjectView"
|
||||
/>
|
||||
<GiftedDialog ref="customDialog" :to-project-id="projectId" />
|
||||
</section>
|
||||
</template>
|
||||
|
||||
@@ -103,24 +97,6 @@ export default class ContactGiftingView extends Vue {
|
||||
description = "";
|
||||
projectId = "";
|
||||
prompt = "";
|
||||
recipientProjectName = "";
|
||||
recipientProjectImage = "";
|
||||
recipientProjectHandleId = "";
|
||||
|
||||
// New context parameters
|
||||
stepType = "giver";
|
||||
giverEntityType = "person" as "person" | "project";
|
||||
recipientEntityType = "person" as "person" | "project";
|
||||
giverProjectId = "";
|
||||
giverProjectName = "";
|
||||
giverProjectImage = "";
|
||||
giverProjectHandleId = "";
|
||||
giverDid = "";
|
||||
recipientDid = "";
|
||||
fromProjectId = "";
|
||||
toProjectId = "";
|
||||
showProjects = false;
|
||||
isFromProjectView = false;
|
||||
|
||||
async created() {
|
||||
try {
|
||||
@@ -148,41 +124,9 @@ export default class ContactGiftingView extends Vue {
|
||||
);
|
||||
}
|
||||
|
||||
this.projectId =
|
||||
(this.$route.query["recipientProjectId"] as string) || "";
|
||||
this.recipientProjectName =
|
||||
(this.$route.query["recipientProjectName"] as string) || "";
|
||||
this.recipientProjectImage =
|
||||
(this.$route.query["recipientProjectImage"] as string) || "";
|
||||
this.recipientProjectHandleId =
|
||||
(this.$route.query["recipientProjectHandleId"] as string) || "";
|
||||
this.projectId = (this.$route.query["projectId"] as string) || "";
|
||||
this.prompt = (this.$route.query["prompt"] as string) ?? this.prompt;
|
||||
|
||||
// Read new context parameters
|
||||
this.stepType = (this.$route.query["stepType"] as string) || "giver";
|
||||
this.giverEntityType =
|
||||
(this.$route.query["giverEntityType"] as "person" | "project") ||
|
||||
"person";
|
||||
this.recipientEntityType =
|
||||
(this.$route.query["recipientEntityType"] as "person" | "project") ||
|
||||
"person";
|
||||
this.giverProjectId =
|
||||
(this.$route.query["giverProjectId"] as string) || "";
|
||||
this.giverProjectName =
|
||||
(this.$route.query["giverProjectName"] as string) || "";
|
||||
this.giverProjectImage =
|
||||
(this.$route.query["giverProjectImage"] as string) || "";
|
||||
this.giverProjectHandleId =
|
||||
(this.$route.query["giverProjectHandleId"] as string) || "";
|
||||
this.giverDid = (this.$route.query["giverDid"] as string) || "";
|
||||
this.recipientDid = (this.$route.query["recipientDid"] as string) || "";
|
||||
this.fromProjectId = (this.$route.query["fromProjectId"] as string) || "";
|
||||
this.toProjectId = (this.$route.query["toProjectId"] as string) || "";
|
||||
this.showProjects =
|
||||
(this.$route.query["showProjects"] as string) === "true";
|
||||
this.isFromProjectView =
|
||||
(this.$route.query["isFromProjectView"] as string) === "true";
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
} catch (err: any) {
|
||||
logger.error("Error retrieving settings & contacts:", err);
|
||||
@@ -200,108 +144,17 @@ export default class ContactGiftingView extends Vue {
|
||||
}
|
||||
}
|
||||
|
||||
openDialog(contact?: GiverReceiverInputInfo | "Unnamed") {
|
||||
if (contact === "Unnamed") {
|
||||
// Special case: Pass undefined to trigger Step 1, but with "Unnamed" pre-selected
|
||||
let recipient: GiverReceiverInputInfo;
|
||||
let giver: GiverReceiverInputInfo | undefined;
|
||||
|
||||
if (this.stepType === "giver") {
|
||||
// We're selecting a giver, so recipient is either a project or the current user
|
||||
if (this.recipientEntityType === "project") {
|
||||
recipient = {
|
||||
did: this.recipientProjectHandleId,
|
||||
name: this.recipientProjectName,
|
||||
image: this.recipientProjectImage,
|
||||
handleId: this.recipientProjectHandleId,
|
||||
};
|
||||
} else {
|
||||
recipient = { did: this.activeDid, name: "You" };
|
||||
}
|
||||
giver = undefined; // Will be set to "Unnamed" in GiftedDialog
|
||||
} else {
|
||||
// We're selecting a recipient, so recipient is "Unnamed" and giver is preserved from context
|
||||
recipient = { did: "", name: "Unnamed" };
|
||||
|
||||
// Preserve the existing giver from the context
|
||||
if (this.giverEntityType === "project") {
|
||||
giver = {
|
||||
did: this.giverProjectHandleId,
|
||||
name: this.giverProjectName,
|
||||
image: this.giverProjectImage,
|
||||
handleId: this.giverProjectHandleId,
|
||||
};
|
||||
} else if (this.giverDid) {
|
||||
giver = {
|
||||
did: this.giverDid,
|
||||
name: this.giverProjectName || "Someone",
|
||||
};
|
||||
} else {
|
||||
giver = { did: this.activeDid, name: "You" };
|
||||
}
|
||||
}
|
||||
|
||||
(this.$refs.customDialog as GiftedDialog).open(
|
||||
giver,
|
||||
recipient,
|
||||
undefined,
|
||||
this.stepType === "giver" ? "Given by Unnamed" : "Given to Unnamed",
|
||||
this.prompt,
|
||||
);
|
||||
// Immediately select "Unnamed" and move to Step 2
|
||||
(this.$refs.customDialog as GiftedDialog).selectGiver();
|
||||
} else {
|
||||
// Regular case: contact is a GiverReceiverInputInfo
|
||||
let giver: GiverReceiverInputInfo;
|
||||
let recipient: GiverReceiverInputInfo;
|
||||
|
||||
if (this.stepType === "giver") {
|
||||
// We're selecting a giver, so the contact becomes the giver
|
||||
giver = contact as GiverReceiverInputInfo; // Safe because we know contact is not "Unnamed" or undefined
|
||||
|
||||
// Recipient is either a project or the current user
|
||||
if (this.recipientEntityType === "project") {
|
||||
recipient = {
|
||||
did: this.recipientProjectHandleId,
|
||||
name: this.recipientProjectName,
|
||||
image: this.recipientProjectImage,
|
||||
handleId: this.recipientProjectHandleId,
|
||||
};
|
||||
} else {
|
||||
recipient = { did: this.activeDid, name: "You" };
|
||||
}
|
||||
} else {
|
||||
// We're selecting a recipient, so the contact becomes the recipient
|
||||
recipient = contact as GiverReceiverInputInfo; // Safe because we know contact is not "Unnamed" or undefined
|
||||
|
||||
// Preserve the existing giver from the context
|
||||
if (this.giverEntityType === "project") {
|
||||
giver = {
|
||||
did: this.giverProjectHandleId,
|
||||
name: this.giverProjectName,
|
||||
image: this.giverProjectImage,
|
||||
handleId: this.giverProjectHandleId,
|
||||
};
|
||||
} else if (this.giverDid) {
|
||||
giver = {
|
||||
did: this.giverDid,
|
||||
name: this.giverProjectName || "Someone",
|
||||
};
|
||||
} else {
|
||||
giver = { did: this.activeDid, name: "You" };
|
||||
}
|
||||
}
|
||||
|
||||
(this.$refs.customDialog as GiftedDialog).open(
|
||||
giver,
|
||||
recipient,
|
||||
undefined,
|
||||
this.stepType === "giver"
|
||||
? "Given by " + (contact?.name || "someone not named")
|
||||
: "Given to " + (contact?.name || "someone not named"),
|
||||
this.prompt,
|
||||
);
|
||||
}
|
||||
openDialog(giver?: GiverReceiverInputInfo) {
|
||||
const recipient = this.projectId
|
||||
? undefined
|
||||
: { did: this.activeDid, name: "you" };
|
||||
(this.$refs.customDialog as GiftedDialog).open(
|
||||
giver,
|
||||
recipient,
|
||||
undefined,
|
||||
"Given by " + (giver?.name || "someone not named"),
|
||||
this.prompt,
|
||||
);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -104,6 +104,7 @@
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { Buffer } from "buffer/";
|
||||
import QRCodeVue3 from "qr-code-generator-vue3";
|
||||
import { Component, Vue } from "vue-facing-decorator";
|
||||
import { Router } from "vue-router";
|
||||
@@ -117,14 +118,20 @@ import { db } from "../db/index";
|
||||
import { Contact } from "../db/tables/contacts";
|
||||
import { getContactJwtFromJwtUrl } from "../libs/crypto";
|
||||
import { decodeEndorserJwt, ETHR_DID_PREFIX } from "../libs/crypto/vc";
|
||||
import * as libsUtil from "../libs/util";
|
||||
import { retrieveSettingsForActiveAccount } from "../db/index";
|
||||
import * as databaseUtil from "../db/databaseUtil";
|
||||
import { setVisibilityUtil } from "../libs/endorserServer";
|
||||
import {
|
||||
CONTACT_CSV_HEADER,
|
||||
CONTACT_IMPORT_CONFIRM_URL_PATH_TIME_SAFARI,
|
||||
generateEndorserJwtUrlForAccount,
|
||||
setVisibilityUtil,
|
||||
} from "../libs/endorserServer";
|
||||
import UserNameDialog from "../components/UserNameDialog.vue";
|
||||
import { generateEndorserJwtUrlForAccount } from "../libs/endorserServer";
|
||||
import { retrieveAccountMetadata } from "../libs/util";
|
||||
import { PlatformServiceFactory } from "@/services/PlatformServiceFactory";
|
||||
import { parseJsonField } from "../db/databaseUtil";
|
||||
import { Account } from "@/db/tables/accounts";
|
||||
|
||||
interface QRScanResult {
|
||||
rawValue?: string;
|
||||
@@ -142,7 +149,7 @@ interface IUserNameDialog {
|
||||
UserNameDialog,
|
||||
},
|
||||
})
|
||||
export default class ContactQRScan extends Vue {
|
||||
export default class ContactQRScanFull extends Vue {
|
||||
$notify!: (notification: NotificationIface, timeout?: number) => void;
|
||||
$router!: Router;
|
||||
|
||||
@@ -151,6 +158,8 @@ export default class ContactQRScan extends Vue {
|
||||
activeDid = "";
|
||||
apiServer = "";
|
||||
givenName = "";
|
||||
isRegistered = false;
|
||||
profileImageUrl = "";
|
||||
qrValue = "";
|
||||
ETHR_DID_PREFIX = ETHR_DID_PREFIX;
|
||||
|
||||
@@ -172,19 +181,22 @@ export default class ContactQRScan extends Vue {
|
||||
this.activeDid = settings.activeDid || "";
|
||||
this.apiServer = settings.apiServer || "";
|
||||
this.givenName = settings.firstName || "";
|
||||
this.isRegistered = !!settings.isRegistered;
|
||||
this.profileImageUrl = settings.profileImageUrl || "";
|
||||
|
||||
const account = await retrieveAccountMetadata(this.activeDid);
|
||||
if (account) {
|
||||
const name =
|
||||
(settings.firstName || "") +
|
||||
(settings.lastName ? ` ${settings.lastName}` : "");
|
||||
this.qrValue = await generateEndorserJwtUrlForAccount(
|
||||
account,
|
||||
!!settings.isRegistered,
|
||||
name,
|
||||
settings.profileImageUrl || "",
|
||||
false,
|
||||
);
|
||||
const publicKeyBase64 = Buffer.from(
|
||||
account.publicKeyHex,
|
||||
"hex",
|
||||
).toString("base64");
|
||||
this.qrValue =
|
||||
CONTACT_CSV_HEADER +
|
||||
"\n" +
|
||||
`"${name}",${account.did},${publicKeyBase64},false,${this.isRegistered}`;
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error("Error initializing component:", {
|
||||
@@ -336,57 +348,69 @@ export default class ContactQRScan extends Vue {
|
||||
|
||||
logger.info("Processing QR code scan result:", rawValue);
|
||||
|
||||
// Extract JWT
|
||||
const jwt = getContactJwtFromJwtUrl(rawValue);
|
||||
if (!jwt) {
|
||||
logger.warn("Invalid QR code format - no JWT found in URL");
|
||||
let contact: Contact;
|
||||
if (rawValue.includes(CONTACT_IMPORT_CONFIRM_URL_PATH_TIME_SAFARI)) {
|
||||
// Extract JWT
|
||||
const jwt = getContactJwtFromJwtUrl(rawValue);
|
||||
if (!jwt) {
|
||||
logger.warn("Invalid QR code format - no JWT found in URL");
|
||||
this.$notify({
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Invalid QR Code",
|
||||
text: "This QR code does not contain valid contact information. Scan a TimeSafari contact QR code.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Process JWT and contact info
|
||||
logger.info("Decoding JWT payload from QR code");
|
||||
const decodedJwt = await decodeEndorserJwt(jwt);
|
||||
if (!decodedJwt?.payload?.own) {
|
||||
logger.warn("Invalid JWT payload - missing 'own' field");
|
||||
this.$notify({
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Invalid Contact Info",
|
||||
text: "The contact information is incomplete or invalid.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const contactInfo = decodedJwt.payload.own;
|
||||
const did = contactInfo.did || decodedJwt.payload.iss;
|
||||
if (!did) {
|
||||
logger.warn("Invalid contact info - missing DID");
|
||||
this.$notify({
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Invalid Contact",
|
||||
text: "The contact DID is missing.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Create contact object
|
||||
contact = {
|
||||
did: did,
|
||||
name: contactInfo.name || "",
|
||||
publicKeyBase64: contactInfo.publicKeyBase64 || "",
|
||||
seesMe: contactInfo.seesMe || false,
|
||||
registered: contactInfo.registered || false,
|
||||
};
|
||||
} else if (rawValue.startsWith(CONTACT_CSV_HEADER)) {
|
||||
const lines = rawValue.split(/\n/);
|
||||
contact = libsUtil.csvLineToContact(lines[1]);
|
||||
} else {
|
||||
this.$notify({
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Invalid QR Code",
|
||||
text: "This QR code does not contain valid contact information. Please scan a TimeSafari contact QR code.",
|
||||
title: "Error",
|
||||
text: "Could not determine the type of contact info. Try again, or tap the QR code to copy it and send it to them.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Process JWT and contact info
|
||||
logger.info("Decoding JWT payload from QR code");
|
||||
const decodedJwt = await decodeEndorserJwt(jwt);
|
||||
if (!decodedJwt?.payload?.own) {
|
||||
logger.warn("Invalid JWT payload - missing 'own' field");
|
||||
this.$notify({
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Invalid Contact Info",
|
||||
text: "The contact information is incomplete or invalid.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const contactInfo = decodedJwt.payload.own;
|
||||
const did = contactInfo.did || decodedJwt.payload.iss;
|
||||
if (!did) {
|
||||
logger.warn("Invalid contact info - missing DID");
|
||||
this.$notify({
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Invalid Contact",
|
||||
text: "The contact DID is missing.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Create contact object
|
||||
const contact = {
|
||||
did: did,
|
||||
name: contactInfo.name || "",
|
||||
email: contactInfo.email || "",
|
||||
phone: contactInfo.phone || "",
|
||||
company: contactInfo.company || "",
|
||||
title: contactInfo.title || "",
|
||||
notes: contactInfo.notes || "",
|
||||
};
|
||||
|
||||
// Add contact but keep scanning
|
||||
logger.info("Adding new contact to database:", {
|
||||
did: contact.did,
|
||||
@@ -468,7 +492,7 @@ export default class ContactQRScan extends Vue {
|
||||
title: "Contact Exists",
|
||||
text: "This contact has already been added to your list.",
|
||||
},
|
||||
3000,
|
||||
5000,
|
||||
);
|
||||
return;
|
||||
}
|
||||
@@ -568,9 +592,19 @@ export default class ContactQRScan extends Vue {
|
||||
);
|
||||
}
|
||||
|
||||
onCopyUrlToClipboard() {
|
||||
async onCopyUrlToClipboard() {
|
||||
const account = (await libsUtil.retrieveFullyDecryptedAccount(
|
||||
this.activeDid,
|
||||
)) as Account;
|
||||
const jwtUrl = await generateEndorserJwtUrlForAccount(
|
||||
account,
|
||||
this.isRegistered,
|
||||
this.givenName,
|
||||
this.profileImageUrl,
|
||||
true,
|
||||
);
|
||||
useClipboard()
|
||||
.copy(this.qrValue)
|
||||
.copy(jwtUrl)
|
||||
.then(() => {
|
||||
this.$notify(
|
||||
{
|
||||
|
||||
@@ -159,6 +159,7 @@
|
||||
|
||||
<script lang="ts">
|
||||
import { AxiosError } from "axios";
|
||||
import { Buffer } from "buffer/";
|
||||
import QRCodeVue3 from "qr-code-generator-vue3";
|
||||
import { Component, Vue } from "vue-facing-decorator";
|
||||
import { useClipboard } from "@vueuse/core";
|
||||
@@ -174,17 +175,20 @@ import * as databaseUtil from "../db/databaseUtil";
|
||||
import { parseJsonField } from "../db/databaseUtil";
|
||||
import { getContactJwtFromJwtUrl } from "../libs/crypto";
|
||||
import {
|
||||
CONTACT_CSV_HEADER,
|
||||
CONTACT_IMPORT_CONFIRM_URL_PATH_TIME_SAFARI,
|
||||
generateEndorserJwtUrlForAccount,
|
||||
register,
|
||||
setVisibilityUtil,
|
||||
} from "../libs/endorserServer";
|
||||
import { decodeEndorserJwt, ETHR_DID_PREFIX } from "../libs/crypto/vc";
|
||||
import { retrieveAccountMetadata } from "../libs/util";
|
||||
import * as libsUtil from "../libs/util";
|
||||
import { Router } from "vue-router";
|
||||
import { logger } from "../utils/logger";
|
||||
import { QRScannerFactory } from "@/services/QRScanner/QRScannerFactory";
|
||||
import { CameraState } from "@/services/QRScanner/types";
|
||||
import { PlatformServiceFactory } from "@/services/PlatformServiceFactory";
|
||||
import { Account } from "@/db/tables/accounts";
|
||||
|
||||
interface QRScanResult {
|
||||
rawValue?: string;
|
||||
@@ -214,6 +218,7 @@ export default class ContactQRScanShow extends Vue {
|
||||
isRegistered = false;
|
||||
qrValue = "";
|
||||
isScanning = false;
|
||||
profileImageUrl = "";
|
||||
error: string | null = null;
|
||||
|
||||
// QR Scanner properties
|
||||
@@ -251,19 +256,21 @@ export default class ContactQRScanShow extends Vue {
|
||||
this.hideRegisterPromptOnNewContact =
|
||||
!!settings.hideRegisterPromptOnNewContact;
|
||||
this.isRegistered = !!settings.isRegistered;
|
||||
this.profileImageUrl = settings.profileImageUrl || "";
|
||||
|
||||
const account = await retrieveAccountMetadata(this.activeDid);
|
||||
const account = await libsUtil.retrieveAccountMetadata(this.activeDid);
|
||||
if (account) {
|
||||
const name =
|
||||
(settings.firstName || "") +
|
||||
(settings.lastName ? ` ${settings.lastName}` : "");
|
||||
this.qrValue = await generateEndorserJwtUrlForAccount(
|
||||
account,
|
||||
!!settings.isRegistered,
|
||||
name,
|
||||
settings.profileImageUrl || "",
|
||||
false,
|
||||
);
|
||||
const publicKeyBase64 = Buffer.from(
|
||||
account.publicKeyHex,
|
||||
"hex",
|
||||
).toString("base64");
|
||||
this.qrValue =
|
||||
CONTACT_CSV_HEADER +
|
||||
"\n" +
|
||||
`"${name}",${account.did},${publicKeyBase64},false,${this.isRegistered}`;
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error("Error initializing component:", {
|
||||
@@ -274,7 +281,7 @@ export default class ContactQRScanShow extends Vue {
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Initialization Error",
|
||||
text: "Failed to initialize QR scanner. Please try again.",
|
||||
text: "Failed to initialize QR renderer or scanner. Please try again.",
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -461,53 +468,68 @@ export default class ContactQRScanShow extends Vue {
|
||||
|
||||
logger.info("Processing QR code scan result:", rawValue);
|
||||
|
||||
// Extract JWT
|
||||
const jwt = getContactJwtFromJwtUrl(rawValue);
|
||||
if (!jwt) {
|
||||
logger.warn("Invalid QR code format - no JWT found in URL");
|
||||
let contact: Contact;
|
||||
if (rawValue.includes(CONTACT_IMPORT_CONFIRM_URL_PATH_TIME_SAFARI)) {
|
||||
const jwt = getContactJwtFromJwtUrl(rawValue);
|
||||
if (!jwt) {
|
||||
logger.warn("Invalid QR code format - no JWT found in URL");
|
||||
this.$notify({
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Invalid QR Code",
|
||||
text: "This QR code does not contain valid contact information. Scan a TimeSafari contact QR code.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
logger.info("Decoding JWT payload from QR code");
|
||||
const decodedJwt = await decodeEndorserJwt(jwt);
|
||||
|
||||
// Process JWT and contact info
|
||||
if (!decodedJwt?.payload?.own) {
|
||||
logger.warn("Invalid JWT payload - missing 'own' field");
|
||||
this.$notify({
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Invalid Contact Info",
|
||||
text: "The contact information is incomplete or invalid.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const contactInfo = decodedJwt.payload.own;
|
||||
const did = contactInfo.did || decodedJwt.payload.iss;
|
||||
if (!did) {
|
||||
logger.warn("Invalid contact info - missing DID");
|
||||
this.$notify({
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Invalid Contact",
|
||||
text: "The contact DID is missing.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Create contact object
|
||||
contact = {
|
||||
did: did,
|
||||
name: contactInfo.name || "",
|
||||
publicKeyBase64: contactInfo.publicKeyBase64 || "",
|
||||
seesMe: contactInfo.seesMe || false,
|
||||
registered: contactInfo.registered || false,
|
||||
};
|
||||
} else if (rawValue.startsWith(CONTACT_CSV_HEADER)) {
|
||||
const lines = rawValue.split(/\n/);
|
||||
contact = libsUtil.csvLineToContact(lines[1]);
|
||||
} else {
|
||||
this.$notify({
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Invalid QR Code",
|
||||
text: "This QR code does not contain valid contact information. Please scan a TimeSafari contact QR code.",
|
||||
title: "Error",
|
||||
text: "Could not determine the type of contact info. Try again, or tap the QR code to copy it and send it to them.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Process JWT and contact info
|
||||
logger.info("Decoding JWT payload from QR code");
|
||||
const decodedJwt = await decodeEndorserJwt(jwt);
|
||||
if (!decodedJwt?.payload?.own) {
|
||||
logger.warn("Invalid JWT payload - missing 'own' field");
|
||||
this.$notify({
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Invalid Contact Info",
|
||||
text: "The contact information is incomplete or invalid.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const contactInfo = decodedJwt.payload.own;
|
||||
const did = contactInfo.did || decodedJwt.payload.iss;
|
||||
if (!did) {
|
||||
logger.warn("Invalid contact info - missing DID");
|
||||
this.$notify({
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Invalid Contact",
|
||||
text: "The contact DID is missing.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Create contact object
|
||||
const contact = {
|
||||
did: did,
|
||||
name: contactInfo.name || "",
|
||||
notes: contactInfo.notes || "",
|
||||
};
|
||||
|
||||
// Add contact but keep scanning
|
||||
logger.info("Adding new contact to database:", {
|
||||
did: contact.did,
|
||||
@@ -649,12 +671,20 @@ export default class ContactQRScanShow extends Vue {
|
||||
});
|
||||
}
|
||||
|
||||
onCopyUrlToClipboard() {
|
||||
//this.onScanDetect([{ rawValue: this.qrValue }]); // good for testing
|
||||
async onCopyUrlToClipboard() {
|
||||
const account = (await libsUtil.retrieveFullyDecryptedAccount(
|
||||
this.activeDid,
|
||||
)) as Account;
|
||||
const jwtUrl = await generateEndorserJwtUrlForAccount(
|
||||
account,
|
||||
this.isRegistered,
|
||||
this.givenName,
|
||||
this.profileImageUrl,
|
||||
true,
|
||||
);
|
||||
useClipboard()
|
||||
.copy(this.qrValue)
|
||||
.copy(jwtUrl)
|
||||
.then(() => {
|
||||
// console.log("Contact URL:", this.qrValue);
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
@@ -772,7 +802,7 @@ export default class ContactQRScanShow extends Vue {
|
||||
title: "Contact Exists",
|
||||
text: "This contact has already been added to your list.",
|
||||
},
|
||||
3000,
|
||||
5000,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -126,7 +126,6 @@
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
v-if="showGiveNumbers"
|
||||
href=""
|
||||
class="text-md bg-gradient-to-b shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-3 py-1.5 rounded-md"
|
||||
:class="showGiveAmountsClassNames()"
|
||||
@click="toggleShowGiveTotals()"
|
||||
@@ -142,7 +141,6 @@
|
||||
</button>
|
||||
|
||||
<button
|
||||
href=""
|
||||
class="text-md bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-3 py-1.5 rounded-md"
|
||||
@click="toggleShowContactAmounts()"
|
||||
>
|
||||
@@ -493,7 +491,7 @@ export default class ContactsView extends Vue {
|
||||
private async processContactJwt() {
|
||||
// handle a contact sent via URL
|
||||
//
|
||||
// For external links, use /contact-import/:jwt with a JWT that has an array of contacts
|
||||
// For external links, use /deep-link/contact-import/:jwt with a JWT that has an array of contacts
|
||||
// because that will do better error checking for things like missing data on iOS platforms.
|
||||
const importedContactJwt = this.$route.query["contactJwt"] as string;
|
||||
if (importedContactJwt) {
|
||||
@@ -619,7 +617,7 @@ export default class ContactsView extends Vue {
|
||||
title: "Error with Invite",
|
||||
text: message,
|
||||
},
|
||||
5000,
|
||||
-1,
|
||||
);
|
||||
}
|
||||
// if we're here, they haven't redirected anywhere, so we'll redirect here without a query parameter
|
||||
@@ -935,45 +933,9 @@ export default class ContactsView extends Vue {
|
||||
}
|
||||
|
||||
private async addContactFromEndorserMobileLine(
|
||||
line: string,
|
||||
lineRaw: string,
|
||||
): Promise<IndexableType> {
|
||||
// Note that Endorser Mobile puts name first, then did, etc.
|
||||
let name = line;
|
||||
let did = "";
|
||||
let publicKeyInput, seesMe, registered;
|
||||
const commaPos1 = line.indexOf(",");
|
||||
if (commaPos1 > -1) {
|
||||
name = line.substring(0, commaPos1).trim();
|
||||
did = line.substring(commaPos1 + 1).trim();
|
||||
const commaPos2 = line.indexOf(",", commaPos1 + 1);
|
||||
if (commaPos2 > -1) {
|
||||
did = line.substring(commaPos1 + 1, commaPos2).trim();
|
||||
publicKeyInput = line.substring(commaPos2 + 1).trim();
|
||||
const commaPos3 = line.indexOf(",", commaPos2 + 1);
|
||||
if (commaPos3 > -1) {
|
||||
publicKeyInput = line.substring(commaPos2 + 1, commaPos3).trim();
|
||||
seesMe = line.substring(commaPos3 + 1).trim() == "true";
|
||||
const commaPos4 = line.indexOf(",", commaPos3 + 1);
|
||||
if (commaPos4 > -1) {
|
||||
seesMe = line.substring(commaPos3 + 1, commaPos4).trim() == "true";
|
||||
registered = line.substring(commaPos4 + 1).trim() == "true";
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// help with potential mistakes while this sharing requires copy-and-paste
|
||||
let publicKeyBase64 = publicKeyInput;
|
||||
if (publicKeyBase64 && /^[0-9A-Fa-f]{66}$/i.test(publicKeyBase64)) {
|
||||
// it must be all hex (compressed public key), so convert
|
||||
publicKeyBase64 = Buffer.from(publicKeyBase64, "hex").toString("base64");
|
||||
}
|
||||
const newContact = {
|
||||
did,
|
||||
name,
|
||||
publicKeyBase64,
|
||||
seesMe,
|
||||
registered,
|
||||
};
|
||||
const newContact = libsUtil.csvLineToContact(lineRaw);
|
||||
const platformService = PlatformServiceFactory.getInstance();
|
||||
const { sql, params } = databaseUtil.generateInsertStatement(
|
||||
newContact as unknown as Record<string, unknown>,
|
||||
@@ -1160,7 +1122,7 @@ export default class ContactsView extends Vue {
|
||||
(regResult.error as string) ||
|
||||
"Something went wrong during registration.",
|
||||
},
|
||||
5000,
|
||||
-1,
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -1194,7 +1156,7 @@ export default class ContactsView extends Vue {
|
||||
title: "Registration Error",
|
||||
text: userMessage,
|
||||
},
|
||||
5000,
|
||||
-1,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1215,7 +1177,6 @@ export default class ContactsView extends Vue {
|
||||
);
|
||||
if (result.success) {
|
||||
//contact.seesMe = visibility; // why doesn't it affect the UI from here?
|
||||
//console.log("Set result & seesMe", result, contact.seesMe, contact.did);
|
||||
if (showSuccessAlert) {
|
||||
this.$notify(
|
||||
{
|
||||
@@ -1431,14 +1392,11 @@ export default class ContactsView extends Vue {
|
||||
}
|
||||
return contact;
|
||||
});
|
||||
// console.log(
|
||||
// "Array of selected contacts:",
|
||||
// JSON.stringify(selectedContacts),
|
||||
// );
|
||||
const contactsJwt = await createEndorserJwtForDid(this.activeDid, {
|
||||
contacts: selectedContacts,
|
||||
});
|
||||
const contactsJwtUrl = APP_SERVER + "/contact-import/" + contactsJwt;
|
||||
const contactsJwtUrl =
|
||||
APP_SERVER + "/deep-link/contact-import/" + contactsJwt;
|
||||
useClipboard()
|
||||
.copy(contactsJwtUrl)
|
||||
.then(() => {
|
||||
|
||||
@@ -66,9 +66,14 @@ const formattedPath = computed(() => {
|
||||
const path = originalPath.value.replace(/^\/+/, "");
|
||||
|
||||
// Log for debugging
|
||||
logger.log("Original Path:", originalPath.value);
|
||||
logger.log("Route Params:", route.params);
|
||||
logger.log("Route Query:", route.query);
|
||||
logger.log(
|
||||
"[DeepLinkError] Original Path:",
|
||||
originalPath.value,
|
||||
"Route Params:",
|
||||
route.params,
|
||||
"Route Query:",
|
||||
route.query,
|
||||
);
|
||||
|
||||
return path;
|
||||
});
|
||||
|
||||
227
src/views/DeepLinkRedirectView.vue
Normal file
227
src/views/DeepLinkRedirectView.vue
Normal file
@@ -0,0 +1,227 @@
|
||||
<template>
|
||||
<!-- CONTENT -->
|
||||
<section id="Content" class="relative w-[100vw] h-[100vh]">
|
||||
<div
|
||||
class="p-6 bg-white w-full max-w-[calc((100vh-env(safe-area-inset-top)-env(safe-area-inset-bottom))*0.4)] mx-auto"
|
||||
>
|
||||
<div class="mb-4">
|
||||
<h1 class="text-xl text-center font-semibold relative mb-4">
|
||||
Redirecting to Time Safari
|
||||
</h1>
|
||||
|
||||
<div v-if="destinationUrl" class="space-y-4">
|
||||
<!-- Platform-specific messaging -->
|
||||
<div class="text-center text-gray-600 mb-4">
|
||||
<p v-if="isMobile">
|
||||
{{
|
||||
isIOS
|
||||
? "Opening Time Safari app on your iPhone..."
|
||||
: "Opening Time Safari app on your Android device..."
|
||||
}}
|
||||
</p>
|
||||
<p v-else>Opening Time Safari app...</p>
|
||||
<p class="text-sm mt-2">
|
||||
<span v-if="isMobile"
|
||||
>If the app doesn't open automatically, use one of these
|
||||
options:</span
|
||||
>
|
||||
<span v-else>Choose how you'd like to open this link:</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Deep Link Button -->
|
||||
<div class="text-center">
|
||||
<a
|
||||
:href="deepLinkUrl || '#'"
|
||||
class="inline-block bg-blue-600 text-white px-6 py-3 rounded-lg font-medium hover:bg-blue-700 transition-colors"
|
||||
@click="handleDeepLinkClick"
|
||||
>
|
||||
<span v-if="isMobile">Open in Time Safari App</span>
|
||||
<span v-else>Try Opening in Time Safari App</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Web Fallback Link -->
|
||||
<div class="text-center">
|
||||
<a
|
||||
:href="webUrl || '#'"
|
||||
target="_blank"
|
||||
class="inline-block bg-gray-600 text-white px-6 py-3 rounded-lg font-medium hover:bg-gray-700 transition-colors"
|
||||
@click="handleWebFallbackClick"
|
||||
>
|
||||
<span v-if="isMobile">Open in Web Browser Instead</span>
|
||||
<span v-else>Open in Web Browser</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Manual Instructions -->
|
||||
<div class="text-center text-sm text-gray-500 mt-4">
|
||||
<p v-if="isMobile">
|
||||
Or manually open:
|
||||
<code class="bg-gray-100 px-2 py-1 rounded">{{
|
||||
deepLinkUrl
|
||||
}}</code>
|
||||
</p>
|
||||
<p v-else>
|
||||
If you have the Time Safari app installed, you can also copy this
|
||||
link:
|
||||
<code class="bg-gray-100 px-2 py-1 rounded">{{
|
||||
deepLinkUrl
|
||||
}}</code>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Platform info for debugging -->
|
||||
<div
|
||||
v-if="isDevelopment"
|
||||
class="text-center text-xs text-gray-400 mt-4"
|
||||
>
|
||||
<p>
|
||||
Platform: {{ isMobile ? (isIOS ? "iOS" : "Android") : "Desktop" }}
|
||||
</p>
|
||||
<p>User Agent: {{ userAgent.substring(0, 50) }}...</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="pageError" class="text-center text-red-500 mb-4">
|
||||
{{ pageError }}
|
||||
</div>
|
||||
|
||||
<div v-else class="text-center text-gray-600">
|
||||
<p>Processing redirect...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { Component, Vue } from "vue-facing-decorator";
|
||||
import { RouteLocationNormalizedLoaded, Router } from "vue-router";
|
||||
|
||||
import { APP_SERVER } from "@/constants/app";
|
||||
import { logger } from "@/utils/logger";
|
||||
import { errorStringForLog } from "@/libs/endorserServer";
|
||||
import { PlatformServiceFactory } from "@/services/PlatformServiceFactory";
|
||||
|
||||
@Component({})
|
||||
export default class DeepLinkRedirectView extends Vue {
|
||||
$router!: Router;
|
||||
$route!: RouteLocationNormalizedLoaded;
|
||||
pageError: string | null = null;
|
||||
destinationUrl: string | null = null; // full path after "/deep-link/"
|
||||
deepLinkUrl: string | null = null; // mobile link starting "timesafari://"
|
||||
webUrl: string | null = null; // web link, eg "https://timesafari.app/..."
|
||||
isDevelopment: boolean = false;
|
||||
userAgent: string = "";
|
||||
private platformService = PlatformServiceFactory.getInstance();
|
||||
|
||||
mounted() {
|
||||
// Get the path from the route parameter (catch-all parameter)
|
||||
const pathParam = this.$route.params.path;
|
||||
|
||||
// If pathParam is an array (catch-all parameter), join it
|
||||
const fullPath = Array.isArray(pathParam) ? pathParam.join("/") : pathParam;
|
||||
|
||||
// Get query parameters from the route
|
||||
const queryParams = this.$route.query;
|
||||
|
||||
// Build query string if there are query parameters
|
||||
let queryString = "";
|
||||
if (Object.keys(queryParams).length > 0) {
|
||||
const searchParams = new URLSearchParams();
|
||||
Object.entries(queryParams).forEach(([key, value]) => {
|
||||
if (value !== undefined && value !== null) {
|
||||
const stringValue = Array.isArray(value) ? value[0] : value;
|
||||
if (stringValue !== null && stringValue !== undefined) {
|
||||
searchParams.append(key, stringValue);
|
||||
}
|
||||
}
|
||||
});
|
||||
queryString = "?" + searchParams.toString();
|
||||
}
|
||||
|
||||
// Combine path with query parameters
|
||||
const fullPathWithQuery = fullPath + queryString;
|
||||
|
||||
this.destinationUrl = fullPathWithQuery;
|
||||
this.deepLinkUrl = `timesafari://${fullPathWithQuery}`;
|
||||
this.webUrl = `${APP_SERVER}/${fullPathWithQuery}`;
|
||||
|
||||
this.isDevelopment = process.env.NODE_ENV !== "production";
|
||||
this.userAgent = navigator.userAgent;
|
||||
|
||||
this.openDeepLink();
|
||||
}
|
||||
|
||||
private openDeepLink() {
|
||||
if (!this.deepLinkUrl || !this.webUrl) {
|
||||
this.pageError =
|
||||
"No deep link was provided. Check the URL and try again.";
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// For mobile, try the deep link URL; for desktop, use the web URL
|
||||
const redirectUrl = this.isMobile ? this.deepLinkUrl : this.webUrl;
|
||||
|
||||
// Method 1: Try window.location.href (works on most browsers)
|
||||
window.location.href = redirectUrl;
|
||||
|
||||
// Method 2: Fallback - create and click a link element
|
||||
setTimeout(() => {
|
||||
try {
|
||||
const link = document.createElement("a");
|
||||
link.href = redirectUrl;
|
||||
link.style.display = "none";
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
"Fallback deep link failed: " + errorStringForLog(error),
|
||||
);
|
||||
this.pageError =
|
||||
"Redirecting to the Time Safari app failed. Please use a manual option below.";
|
||||
}
|
||||
}, 100);
|
||||
} catch (error) {
|
||||
logger.error("Deep link redirect failed: " + errorStringForLog(error));
|
||||
this.pageError =
|
||||
"Unable to open the Time Safari app. Please use a manual option below.";
|
||||
}
|
||||
}
|
||||
|
||||
private handleDeepLinkClick(event: Event) {
|
||||
if (!this.deepLinkUrl) return;
|
||||
|
||||
// Prevent default to handle the click manually
|
||||
event.preventDefault();
|
||||
|
||||
this.openDeepLink();
|
||||
}
|
||||
|
||||
private handleWebFallbackClick(event: Event) {
|
||||
if (!this.webUrl) return;
|
||||
|
||||
// Get platform capabilities
|
||||
const capabilities = this.platformService.getCapabilities();
|
||||
|
||||
// For mobile, try to open in a new tab/window
|
||||
if (capabilities.isMobile) {
|
||||
event.preventDefault();
|
||||
window.open(this.webUrl, "_blank");
|
||||
}
|
||||
// For desktop, let the default behavior happen (opens in same tab)
|
||||
}
|
||||
|
||||
// Computed properties for template
|
||||
get isMobile(): boolean {
|
||||
return this.platformService.getCapabilities().isMobile;
|
||||
}
|
||||
|
||||
get isIOS(): boolean {
|
||||
return this.platformService.getCapabilities().isIOS;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -523,9 +523,7 @@ export default class DiscoverView extends Vue {
|
||||
}
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
} catch (e: any) {
|
||||
logger.error("Error with search all:", e);
|
||||
// this sometimes gives different information
|
||||
logger.error("Error with search all (error added): " + e);
|
||||
logger.error("Error with search all: " + errorStringForLog(e));
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
@@ -617,7 +615,7 @@ export default class DiscoverView extends Vue {
|
||||
}
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
} catch (e: any) {
|
||||
logger.error("Error with search local:", e);
|
||||
logger.error("Error with search local: " + errorStringForLog(e));
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
@@ -788,7 +786,7 @@ export default class DiscoverView extends Vue {
|
||||
const route = {
|
||||
path: this.isProjectsActive
|
||||
? "/project/" + encodeURIComponent(id)
|
||||
: "/userProfile/" + encodeURIComponent(id),
|
||||
: "/user-profile/" + encodeURIComponent(id),
|
||||
};
|
||||
this.$router.push(route);
|
||||
}
|
||||
|
||||
@@ -826,7 +826,7 @@ export default class GiftedDetails extends Vue {
|
||||
}
|
||||
|
||||
if (!result.success) {
|
||||
const errorMessage = this.getGiveCreationErrorMessage(result);
|
||||
const errorMessage = result.error;
|
||||
logger.error("Error with give creation result:", result);
|
||||
this.$notify(
|
||||
{
|
||||
@@ -899,19 +899,6 @@ export default class GiftedDetails extends Vue {
|
||||
|
||||
// Helper functions for readability
|
||||
|
||||
/**
|
||||
* @param result direct response eg. ErrorResult or SuccessResult (potentially with embedded "data")
|
||||
* @returns best guess at an error message
|
||||
*/
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
getGiveCreationErrorMessage(result: any) {
|
||||
return (
|
||||
result.error?.userMessage ||
|
||||
result.error?.error ||
|
||||
result.response?.data?.error?.message
|
||||
);
|
||||
}
|
||||
|
||||
explainData() {
|
||||
this.$notify(
|
||||
{
|
||||
|
||||
@@ -24,11 +24,11 @@
|
||||
<!-- eslint-disable prettier/prettier max-len -->
|
||||
<div>
|
||||
<p>
|
||||
This app focuses on gifts & gratitude, using them to build cool things together with your network.
|
||||
This app focuses on raw gratitude, using it to build cool things together with your network.
|
||||
</p>
|
||||
|
||||
<p class="ml-4">
|
||||
If you'd like to see the page-by-page help,
|
||||
If you'd like to see the page-by-page help again,
|
||||
<span
|
||||
class="text-blue-500 cursor-pointer"
|
||||
@click="unsetFinishedOnboarding()"
|
||||
@@ -37,14 +37,16 @@
|
||||
|
||||
<h2 class="text-xl font-semibold">What is the idea here?</h2>
|
||||
<p>
|
||||
We are building networks of people who want to grow good society from the ground up, using modern
|
||||
technology that connects people peer-to-peer.
|
||||
First of all, let's showcase gratitude: see what people have given, and recognize
|
||||
gifts you've seen. This is done in a way that leaves a permanent record -- one that
|
||||
came from you, and one that the recipient can prove it was for them. This can be
|
||||
personally gratifying, but it extends to broader work: volunteers get
|
||||
confirmation of activity, and they can selectively show off their contributions
|
||||
and network.
|
||||
We are building networks of people who want to grow good society from the ground up, using
|
||||
modern technology that connects people peer-to-peer.
|
||||
First of all, let's showcase gratitude: see what people have given, and recognize gifts
|
||||
you've seen. This is done in a way that leaves a permanent record -- one that provably
|
||||
came from you, and one that the recipient can prove they were mentioned.
|
||||
This can be personally gratifying, but it extends to broader work: volunteers get
|
||||
confirmation of activity, and they can selectively show off their contributions and
|
||||
network.
|
||||
This is a way to build trust and reputation. It's a way to build a network of people who
|
||||
are willing to help each other.
|
||||
</p>
|
||||
<p class="mt-2">
|
||||
With this, you highlight giving and you also offer help --
|
||||
@@ -555,9 +557,6 @@
|
||||
initiative.
|
||||
</p>
|
||||
|
||||
<h2 class="text-xl font-semibold">What app version is this?</h2>
|
||||
<p>{{ package.version }} ({{ commitHash }})</p>
|
||||
|
||||
<h2 class="text-xl font-semibold">
|
||||
I have other questions or feedback, like getting a new profile or removing my data or requesting an improvement.
|
||||
</h2>
|
||||
@@ -567,6 +566,28 @@
|
||||
>info@TimeSafari.app</a
|
||||
>
|
||||
</p>
|
||||
|
||||
<h2 class="text-xl font-semibold">What app version is this?</h2>
|
||||
<p>{{ package.version }} ({{ commitHash }})</p>
|
||||
|
||||
<div v-if="Capacitor.isNativePlatform()">
|
||||
<h2 class="text-xl font-semibold">
|
||||
Do I have the latest version?
|
||||
</h2>
|
||||
<p v-if="Capacitor.getPlatform() === 'ios'">
|
||||
<a href="https://apps.apple.com/us/app/time-safari/id6742664907" target="_blank" class="text-blue-500">
|
||||
Check the App Store.
|
||||
</a>
|
||||
</p>
|
||||
<p v-else-if="Capacitor.getPlatform() === 'android'">
|
||||
<a href="https://timesafari.app/app.apk" target="_blank" class="text-blue-500">
|
||||
Download the latest APK to see.
|
||||
</a>
|
||||
</p>
|
||||
<p v-else>
|
||||
Sorry, your platform of '{{ Capacitor.getPlatform() }}' is not recognized.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- eslint enable -->
|
||||
</section>
|
||||
@@ -603,6 +624,7 @@ export default class HelpView extends Vue {
|
||||
showVerifiable = false;
|
||||
|
||||
APP_SERVER = APP_SERVER;
|
||||
Capacitor = Capacitor;
|
||||
|
||||
// Ideally, we put no functionality in here, especially in the setup,
|
||||
// because we never want this page to have a chance of throwing an error.
|
||||
|
||||
@@ -117,73 +117,101 @@ Raymer * @version 1.0.0 */
|
||||
</div>
|
||||
|
||||
<div v-else id="sectionRecordSomethingGiven">
|
||||
<!-- Record Quick-Action -->
|
||||
<div class="mb-6">
|
||||
<div class="flex gap-2 items-center mb-2">
|
||||
<h2 class="text-xl font-bold">Record something given by:</h2>
|
||||
<button
|
||||
class="block ms-auto text-center text-white bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] p-2 rounded-full"
|
||||
@click="openGiftedPrompts()"
|
||||
>
|
||||
<font-awesome
|
||||
icon="lightbulb"
|
||||
class="block text-center w-[1em]"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
<!-- !isCreatingIdentifier && isRegistered -->
|
||||
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<button
|
||||
type="button"
|
||||
class="text-center text-base uppercase bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-3 py-2 rounded-lg"
|
||||
@click="openDialogPerson()"
|
||||
>
|
||||
<font-awesome icon="user" />
|
||||
Person
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="text-center text-base uppercase bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-3 py-2 rounded-lg"
|
||||
@click="openProjectDialog()"
|
||||
>
|
||||
<font-awesome icon="folder-open" />
|
||||
Project
|
||||
</button>
|
||||
</div>
|
||||
<!-- show the actions for recognizing a give -->
|
||||
<div class="flex">
|
||||
<h2 class="text-xl font-bold">What have you seen someone do?</h2>
|
||||
<button
|
||||
class="ml-2 block text-xs text-center bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-1 rounded-md"
|
||||
@click="openGiftedPrompts()"
|
||||
>
|
||||
<font-awesome icon="lightbulb" class="fa-fw" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<ul
|
||||
class="grid grid-cols-4 sm:grid-cols-5 md:grid-cols-6 gap-x-3 gap-y-5 text-center mt-4"
|
||||
>
|
||||
<li @click="openDialog()">
|
||||
<img
|
||||
src="../assets/blank-square.svg"
|
||||
class="mx-auto border border-blue-500 rounded-md mb-1 cursor-pointer"
|
||||
/>
|
||||
<h3
|
||||
class="text-xs text-blue-500 italic font-medium text-ellipsis whitespace-nowrap overflow-hidden cursor-pointer"
|
||||
>
|
||||
Unnamed/Unknown
|
||||
</h3>
|
||||
</li>
|
||||
<li v-if="allContacts.length === 0" class="text-sm">
|
||||
(Add friends to see more people worthy of recognition.)
|
||||
</li>
|
||||
<li
|
||||
v-for="contact in allContacts.slice(0, 6)"
|
||||
:key="contact.did"
|
||||
@click="openDialog(contact)"
|
||||
>
|
||||
<EntityIcon
|
||||
:contact="contact"
|
||||
:icon-size="64"
|
||||
class="mx-auto border border-blue-500 rounded-md mb-1 cursor-pointer"
|
||||
/>
|
||||
<h3
|
||||
class="text-xs text-blue-500 font-medium text-ellipsis whitespace-nowrap overflow-hidden cursor-pointer"
|
||||
>
|
||||
{{ contact.name || contact.did }}
|
||||
</h3>
|
||||
</li>
|
||||
<li>
|
||||
<router-link
|
||||
v-if="allContacts.length >= 6"
|
||||
:to="{ name: 'contact-gift' }"
|
||||
class="flex align-bottom text-xs text-blue-500 mt-12 cursor-pointer"
|
||||
>
|
||||
... or someone else...
|
||||
</router-link>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<GiftedDialog ref="customDialog" :show-projects="showProjectsDialog" />
|
||||
<GiftedDialog ref="customDialog" />
|
||||
<GiftedPrompts ref="giftedPrompts" />
|
||||
<FeedFilters ref="feedFilters" />
|
||||
|
||||
<div class="relative">
|
||||
<button
|
||||
v-if="isRegistered"
|
||||
class="absolute right-6 bottom-0 transform translate-y-1/2 text-center text-4xl leading-none bg-green-600 text-white w-14 py-2.5 rounded-full"
|
||||
@click="openDialog()"
|
||||
>
|
||||
<font-awesome icon="plus" class="fa-fw" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Results List -->
|
||||
<div class="mt-4 mb-4">
|
||||
<div class="flex gap-2 items-center mb-3">
|
||||
<h2 class="text-xl font-bold">Latest Activity</h2>
|
||||
<button
|
||||
v-if="resultsAreFiltered()"
|
||||
class="block ms-auto text-center text-white bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] p-2 rounded-full"
|
||||
@click="openFeedFilters()"
|
||||
>
|
||||
<font-awesome
|
||||
icon="filter"
|
||||
class="block text-center w-[1em] translate-y-[0.05em]"
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
v-else
|
||||
class="block ms-auto text-center text-white bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] p-2 rounded-full"
|
||||
@click="openFeedFilters()"
|
||||
>
|
||||
<font-awesome
|
||||
icon="filter"
|
||||
class="block text-center w-[1em] translate-y-[0.05em]"
|
||||
/>
|
||||
</button>
|
||||
<div class="flex items-center mb-4">
|
||||
<h2 class="text-xl font-bold flex items-center gap-4">
|
||||
Latest Activity
|
||||
<button
|
||||
v-if="resultsAreFiltered()"
|
||||
class="bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] px-3 py-1.5 rounded-md text-xs text-white"
|
||||
@click="openFeedFilters()"
|
||||
>
|
||||
<font-awesome icon="filter" class="fa-fw" />
|
||||
</button>
|
||||
<button
|
||||
v-else
|
||||
class="bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] px-3 py-1.5 rounded-md text-xs text-white"
|
||||
@click="openFeedFilters()"
|
||||
>
|
||||
<font-awesome icon="filter" class="fa-fw" />
|
||||
</button>
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div
|
||||
@@ -446,7 +474,6 @@ export default class HomeView extends Vue {
|
||||
selectedImageData: Blob | null = null;
|
||||
isImageViewerOpen = false;
|
||||
imageCache: Map<string, Blob | null> = new Map();
|
||||
showProjectsDialog = false;
|
||||
|
||||
/**
|
||||
* Initializes the component on mount
|
||||
@@ -492,7 +519,6 @@ export default class HomeView extends Vue {
|
||||
// Retrieve DIDs with better error handling
|
||||
try {
|
||||
this.allMyDids = await retrieveAccountDids();
|
||||
logConsoleAndDb(`[HomeView] Retrieved ${this.allMyDids.length} DIDs`);
|
||||
} catch (error) {
|
||||
logConsoleAndDb(`[HomeView] Failed to retrieve DIDs: ${error}`, true);
|
||||
throw new Error(
|
||||
@@ -525,9 +551,6 @@ export default class HomeView extends Vue {
|
||||
if (USE_DEXIE_DB) {
|
||||
settings = await retrieveSettingsForActiveAccount();
|
||||
}
|
||||
logConsoleAndDb(
|
||||
`[HomeView] Retrieved settings for ${settings.activeDid || "no active DID"}`,
|
||||
);
|
||||
} catch (error) {
|
||||
logConsoleAndDb(
|
||||
`[HomeView] Failed to retrieve settings: ${error}`,
|
||||
@@ -554,9 +577,6 @@ export default class HomeView extends Vue {
|
||||
if (USE_DEXIE_DB) {
|
||||
this.allContacts = await db.contacts.toArray();
|
||||
}
|
||||
logConsoleAndDb(
|
||||
`[HomeView] Retrieved ${this.allContacts.length} contacts`,
|
||||
);
|
||||
} catch (error) {
|
||||
logConsoleAndDb(
|
||||
`[HomeView] Failed to retrieve contacts: ${error}`,
|
||||
@@ -614,9 +634,6 @@ export default class HomeView extends Vue {
|
||||
});
|
||||
}
|
||||
this.isRegistered = true;
|
||||
logConsoleAndDb(
|
||||
`[HomeView] User ${this.activeDid} is now registered`,
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
logConsoleAndDb(
|
||||
@@ -658,11 +675,6 @@ export default class HomeView extends Vue {
|
||||
this.newOffersToUserHitLimit = offersToUser.hitLimit;
|
||||
this.numNewOffersToUserProjects = offersToProjects.data.length;
|
||||
this.newOffersToUserProjectsHitLimit = offersToProjects.hitLimit;
|
||||
|
||||
logConsoleAndDb(
|
||||
`[HomeView] Retrieved ${this.numNewOffersToUser} user offers and ` +
|
||||
`${this.numNewOffersToUserProjects} project offers`,
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
logConsoleAndDb(
|
||||
@@ -1610,33 +1622,17 @@ export default class HomeView extends Vue {
|
||||
* @param giver Optional contact info for giver
|
||||
* @param description Optional gift description
|
||||
*/
|
||||
openDialog(giver?: GiverReceiverInputInfo | "Unnamed", description?: string) {
|
||||
if (giver === "Unnamed") {
|
||||
// Special case: Pass undefined to trigger Step 1, but with "Unnamed" pre-selected
|
||||
(this.$refs.customDialog as GiftedDialog).open(
|
||||
undefined,
|
||||
{
|
||||
did: this.activeDid,
|
||||
name: "You",
|
||||
} as GiverReceiverInputInfo,
|
||||
undefined,
|
||||
"Given by Unnamed",
|
||||
description,
|
||||
);
|
||||
// Immediately select "Unnamed" and move to Step 2
|
||||
(this.$refs.customDialog as GiftedDialog).selectGiver();
|
||||
} else {
|
||||
(this.$refs.customDialog as GiftedDialog).open(
|
||||
giver,
|
||||
{
|
||||
did: this.activeDid,
|
||||
name: "You",
|
||||
} as GiverReceiverInputInfo,
|
||||
undefined,
|
||||
"Given by " + (giver?.name || "someone not named"),
|
||||
description,
|
||||
);
|
||||
}
|
||||
openDialog(giver?: GiverReceiverInputInfo, description?: string) {
|
||||
(this.$refs.customDialog as GiftedDialog).open(
|
||||
giver,
|
||||
{
|
||||
did: this.activeDid,
|
||||
name: "you",
|
||||
} as GiverReceiverInputInfo,
|
||||
undefined,
|
||||
"Given by " + (giver?.name || "someone not named"),
|
||||
description,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1832,7 +1828,7 @@ export default class HomeView extends Vue {
|
||||
this.axios,
|
||||
);
|
||||
|
||||
if (result.type === "success") {
|
||||
if (result.success) {
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
@@ -1870,18 +1866,5 @@ export default class HomeView extends Vue {
|
||||
this.$router.push({ name: "contact-qr" });
|
||||
}
|
||||
}
|
||||
|
||||
openDialogPerson(
|
||||
giver?: GiverReceiverInputInfo | "Unnamed",
|
||||
description?: string,
|
||||
) {
|
||||
this.showProjectsDialog = false;
|
||||
this.openDialog(giver, description);
|
||||
}
|
||||
|
||||
openProjectDialog() {
|
||||
this.showProjectsDialog = true;
|
||||
(this.$refs.customDialog as any).open();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -83,7 +83,7 @@
|
||||
<span
|
||||
v-else
|
||||
class="text-center text-slate-500 cursor-pointer"
|
||||
:title="inviteLink(invite.jwt)"
|
||||
:title="invite.inviteIdentifier"
|
||||
@click="
|
||||
showInvite(
|
||||
invite.inviteIdentifier,
|
||||
@@ -241,7 +241,7 @@ export default class InviteOneView extends Vue {
|
||||
}
|
||||
|
||||
inviteLink(jwt: string): string {
|
||||
return APP_SERVER + "/invite-one-accept/" + jwt;
|
||||
return APP_SERVER + "/deep-link/invite-one-accept/" + jwt;
|
||||
}
|
||||
|
||||
copyInviteAndNotify(inviteId: string, jwt: string) {
|
||||
@@ -324,7 +324,7 @@ export default class InviteOneView extends Vue {
|
||||
);
|
||||
await axios.post(
|
||||
this.apiServer + "/api/userUtil/invite",
|
||||
{ inviteIdentifier, inviteJwt, notes, expiresAt },
|
||||
{ inviteJwt, notes, expiresAt },
|
||||
{ headers },
|
||||
);
|
||||
const newInvite = {
|
||||
|
||||
@@ -720,7 +720,7 @@ export default class OnboardMeetingView extends Vue {
|
||||
|
||||
onboardMeetingMembersLink(): string {
|
||||
if (this.currentMeeting) {
|
||||
return `${APP_SERVER}/onboard-meeting-members/${this.currentMeeting?.groupId}?password=${encodeURIComponent(
|
||||
return `${APP_SERVER}/deep-link/onboard-meeting-members/${this.currentMeeting?.groupId}?password=${encodeURIComponent(
|
||||
this.currentMeeting?.password || "",
|
||||
)}`;
|
||||
}
|
||||
|
||||
@@ -27,6 +27,12 @@
|
||||
>
|
||||
<font-awesome icon="pen" class="text-sm text-blue-500 ml-2 mb-1" />
|
||||
</button>
|
||||
<button title="Copy Link to Project" @click="onCopyLinkClick()">
|
||||
<font-awesome
|
||||
icon="link"
|
||||
class="text-sm text-slate-500 ml-2 mb-1"
|
||||
/>
|
||||
</button>
|
||||
</h2>
|
||||
</div>
|
||||
</div>
|
||||
@@ -52,16 +58,28 @@
|
||||
icon="user"
|
||||
class="fa-fw text-slate-400"
|
||||
></font-awesome>
|
||||
{{ issuerInfoObject?.displayName }}
|
||||
<span v-if="!serverUtil.isEmptyOrHiddenDid(issuer)">
|
||||
<a :href="`/did/${issuer}`" class="text-blue-500">
|
||||
<span class="truncate inline-block max-w-[calc(100%-2rem)]">
|
||||
{{ issuerInfoObject?.displayName }}
|
||||
</span>
|
||||
|
||||
<span
|
||||
v-if="!serverUtil.isHiddenDid(issuer)"
|
||||
class="inline-flex items-center"
|
||||
>
|
||||
<router-link
|
||||
:to="{
|
||||
path: '/did/' + encodeURIComponent(issuer),
|
||||
}"
|
||||
class="text-blue-500 ml-1"
|
||||
title="See more about this person"
|
||||
>
|
||||
<font-awesome
|
||||
icon="arrow-up-right-from-square"
|
||||
class="fa-fw"
|
||||
/>
|
||||
</a>
|
||||
</router-link>
|
||||
</span>
|
||||
<span v-else-if="serverUtil.isHiddenDid(issuer)">
|
||||
<span v-if="serverUtil.isHiddenDid(issuer)" class="ml-1">
|
||||
<font-awesome
|
||||
icon="info-circle"
|
||||
class="fa-fw text-blue-500 cursor-pointer"
|
||||
@@ -105,7 +123,7 @@
|
||||
class="fa-fw text-slate-400"
|
||||
></font-awesome>
|
||||
<a
|
||||
:href="addScheme(url)"
|
||||
:href="ensureScheme(url)"
|
||||
target="_blank"
|
||||
class="underline text-blue-500"
|
||||
>
|
||||
@@ -196,11 +214,63 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<GiftedDialog
|
||||
ref="giveDialogToThis"
|
||||
:to-project-id="projectId"
|
||||
:is-from-project-view="true"
|
||||
/>
|
||||
<div v-if="activeDid && isRegistered">
|
||||
<div class="text-center">
|
||||
<p class="mt-2 mt-4 text-center">Record a contribution from:</p>
|
||||
</div>
|
||||
<ul
|
||||
class="grid grid-cols-4 sm:grid-cols-5 md:grid-cols-6 gap-x-3 gap-y-5 text-center mb-5 mt-2"
|
||||
>
|
||||
<li @click="openGiftDialogToProject({ name: 'you', did: activeDid })">
|
||||
<font-awesome
|
||||
icon="hand"
|
||||
class="fa-fw text-blue-500 text-5xl cursor-pointer"
|
||||
/>
|
||||
<h3
|
||||
class="mt-5 text-xs text-blue-500 font-medium text-ellipsis whitespace-nowrap overflow-hidden cursor-pointer"
|
||||
>
|
||||
You
|
||||
</h3>
|
||||
</li>
|
||||
<li @click="openGiftDialogToProject()">
|
||||
<img
|
||||
src="../assets/blank-square.svg"
|
||||
class="mx-auto border border-blue-300 rounded-md mb-1 cursor-pointer"
|
||||
/>
|
||||
<h3
|
||||
class="text-xs text-blue-500 italic font-medium text-ellipsis whitespace-nowrap overflow-hidden cursor-pointer"
|
||||
>
|
||||
Unnamed/Unknown
|
||||
</h3>
|
||||
</li>
|
||||
<li
|
||||
v-for="contact in allContacts.slice(0, 5)"
|
||||
:key="contact.did"
|
||||
@click="openGiftDialogToProject(contact)"
|
||||
>
|
||||
<EntityIcon
|
||||
:contact="contact"
|
||||
:icon-size="64"
|
||||
class="mx-auto border border-blue-300 rounded-md mb-1 cursor-pointer"
|
||||
/>
|
||||
<h3
|
||||
class="text-xs text-blue-500 font-medium text-ellipsis whitespace-nowrap overflow-hidden cursor-pointer"
|
||||
>
|
||||
{{ contact.name || "(no name)" }}
|
||||
</h3>
|
||||
</li>
|
||||
<li>
|
||||
<span
|
||||
v-if="allContacts.length >= 5"
|
||||
class="flex align-bottom text-xs text-blue-500 mt-12 cursor-pointer"
|
||||
@click="onClickAllContactsGifting()"
|
||||
>
|
||||
... or someone else...
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<GiftedDialog ref="giveDialogToThis" :to-project-id="projectId" />
|
||||
|
||||
<!-- Offers & Gifts to & from this -->
|
||||
<div class="grid items-start grid-cols-1 sm:grid-cols-3 gap-4 mt-4">
|
||||
@@ -466,12 +536,7 @@
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<GiftedDialog
|
||||
ref="giveDialogFromThis"
|
||||
:from-project-id="projectId"
|
||||
:show-projects="true"
|
||||
:is-from-project-view="true"
|
||||
/>
|
||||
<GiftedDialog ref="giveDialogFromThis" :from-project-id="projectId" />
|
||||
|
||||
<h3 class="text-lg font-bold mb-3 mt-4">
|
||||
Benefitted From This Project
|
||||
@@ -577,7 +642,7 @@ import TopMessage from "../components/TopMessage.vue";
|
||||
import QuickNav from "../components/QuickNav.vue";
|
||||
import EntityIcon from "../components/EntityIcon.vue";
|
||||
import ProjectIcon from "../components/ProjectIcon.vue";
|
||||
import { NotificationIface, USE_DEXIE_DB } from "../constants/app";
|
||||
import { APP_SERVER, NotificationIface, USE_DEXIE_DB } from "../constants/app";
|
||||
import * as databaseUtil from "../db/databaseUtil";
|
||||
import {
|
||||
db,
|
||||
@@ -591,6 +656,7 @@ import { retrieveAccountDids } from "../libs/util";
|
||||
import HiddenDidDialog from "../components/HiddenDidDialog.vue";
|
||||
import { logger } from "../utils/logger";
|
||||
import { PlatformServiceFactory } from "@/services/PlatformServiceFactory";
|
||||
import { useClipboard } from "@vueuse/core";
|
||||
/**
|
||||
* Project View Component
|
||||
* @author Matthew Raymer
|
||||
@@ -787,6 +853,28 @@ export default class ProjectViewView extends Vue {
|
||||
});
|
||||
}
|
||||
|
||||
onCopyLinkClick() {
|
||||
const shortestProjectId = this.projectId.startsWith(
|
||||
serverUtil.ENDORSER_CH_HANDLE_PREFIX,
|
||||
)
|
||||
? this.projectId.substring(serverUtil.ENDORSER_CH_HANDLE_PREFIX.length)
|
||||
: this.projectId;
|
||||
const deepLink = `${APP_SERVER}/deep-link/project/${shortestProjectId}`;
|
||||
useClipboard()
|
||||
.copy(deepLink)
|
||||
.then(() => {
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "toast",
|
||||
title: "Copied",
|
||||
text: "A link to this project was copied to the clipboard.",
|
||||
},
|
||||
2000,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
// Isn't there a better way to make this available to the template?
|
||||
expandText() {
|
||||
this.expanded = true;
|
||||
@@ -1182,53 +1270,21 @@ export default class ProjectViewView extends Vue {
|
||||
);
|
||||
}
|
||||
|
||||
openGiftDialogToProject(
|
||||
contact?: libsUtil.GiverReceiverInputInfo | "Unnamed",
|
||||
) {
|
||||
if (contact === "Unnamed") {
|
||||
// Special case: Pass undefined to trigger Step 1, but with "Unnamed" pre-selected
|
||||
(this.$refs.giveDialogToThis as GiftedDialog).open(
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
"Given by Unnamed to this project",
|
||||
);
|
||||
// Immediately select "Unnamed" and move to Step 2
|
||||
(this.$refs.giveDialogToThis as GiftedDialog).selectGiver();
|
||||
} else {
|
||||
// Open straight to Step 2 with current user as giver and current project as recipient
|
||||
(this.$refs.giveDialogToThis as GiftedDialog).open(
|
||||
{
|
||||
did: this.activeDid,
|
||||
name: "You",
|
||||
},
|
||||
{
|
||||
did: this.issuer,
|
||||
name: this.name,
|
||||
handleId: this.projectId,
|
||||
image: this.imageUrl,
|
||||
},
|
||||
undefined,
|
||||
`Given to ${this.name}`,
|
||||
);
|
||||
}
|
||||
openGiftDialogToProject(contact?: libsUtil.GiverReceiverInputInfo) {
|
||||
(this.$refs.giveDialogToThis as GiftedDialog).open(
|
||||
contact,
|
||||
undefined,
|
||||
undefined,
|
||||
(contact?.name || "Someone not named") + ` gave to this project`,
|
||||
);
|
||||
}
|
||||
|
||||
openGiftDialogFromProject() {
|
||||
// Set the project as giver and the current user as recipient
|
||||
(this.$refs.giveDialogFromThis as GiftedDialog).open(
|
||||
{
|
||||
did: undefined,
|
||||
name: this.name,
|
||||
handleId: this.projectId,
|
||||
image: this.imageUrl,
|
||||
},
|
||||
undefined,
|
||||
{ did: this.activeDid, name: "You" },
|
||||
undefined,
|
||||
`${this.name} gave to you`,
|
||||
undefined,
|
||||
undefined,
|
||||
true,
|
||||
`This project gave to you`,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1281,7 +1337,7 @@ export default class ProjectViewView extends Vue {
|
||||
}
|
||||
|
||||
// return an HTTPS URL if it's not a global URL
|
||||
addScheme(url: string) {
|
||||
ensureScheme(url: string) {
|
||||
if (!libsUtil.isGlobalUri(url)) {
|
||||
return "https://" + url;
|
||||
}
|
||||
@@ -1410,7 +1466,7 @@ export default class ProjectViewView extends Vue {
|
||||
this.apiServer,
|
||||
this.axios,
|
||||
);
|
||||
if (result.type === "success") {
|
||||
if (result.success) {
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
@@ -1442,7 +1498,13 @@ export default class ProjectViewView extends Vue {
|
||||
}
|
||||
|
||||
openHiddenDidDialog() {
|
||||
const shortestProjectId = this.projectId.startsWith(
|
||||
serverUtil.ENDORSER_CH_HANDLE_PREFIX,
|
||||
)
|
||||
? this.projectId.substring(serverUtil.ENDORSER_CH_HANDLE_PREFIX.length)
|
||||
: this.projectId;
|
||||
(this.$refs.hiddenDidDialog as HiddenDidDialog).open(
|
||||
"project/" + shortestProjectId,
|
||||
"creator",
|
||||
this.issuerVisibleToDids,
|
||||
this.allContacts,
|
||||
|
||||
@@ -155,7 +155,7 @@ import { Contact } from "../db/tables/contacts";
|
||||
import {
|
||||
GenericCredWrapper,
|
||||
GenericVerifiableCredential,
|
||||
ErrorResult,
|
||||
CreateAndSubmitClaimResult,
|
||||
} from "../interfaces";
|
||||
import {
|
||||
BVC_MEETUPS_PROJECT_CLAIM_ID,
|
||||
@@ -298,28 +298,29 @@ export default class QuickActionBvcBeginView extends Vue {
|
||||
}
|
||||
|
||||
// in parallel, make a confirmation for each selected claim and send them all to the server
|
||||
const confirmResults = await Promise.allSettled(
|
||||
this.claimsToConfirmSelected.map(async (jwtId) => {
|
||||
const record = this.claimsToConfirm.find(
|
||||
(claim) => claim.id === jwtId,
|
||||
);
|
||||
if (!record) {
|
||||
return { type: "error", error: "Record not found." };
|
||||
}
|
||||
return createAndSubmitConfirmation(
|
||||
this.activeDid,
|
||||
record.claim as GenericVerifiableCredential,
|
||||
record.id,
|
||||
record.handleId,
|
||||
this.apiServer,
|
||||
axios,
|
||||
);
|
||||
}),
|
||||
);
|
||||
const confirmResults: PromiseSettledResult<CreateAndSubmitClaimResult>[] =
|
||||
await Promise.allSettled(
|
||||
this.claimsToConfirmSelected.map(async (jwtId) => {
|
||||
const record = this.claimsToConfirm.find(
|
||||
(claim) => claim.id === jwtId,
|
||||
);
|
||||
if (!record) {
|
||||
return { success: false, error: "Record not found." };
|
||||
}
|
||||
return createAndSubmitConfirmation(
|
||||
this.activeDid,
|
||||
record.claim as GenericVerifiableCredential,
|
||||
record.id,
|
||||
record.handleId,
|
||||
this.apiServer,
|
||||
axios,
|
||||
);
|
||||
}),
|
||||
);
|
||||
// check for any rejected confirmations
|
||||
const confirmsSucceeded = confirmResults.filter(
|
||||
(result) =>
|
||||
result.status === "fulfilled" && result.value.type === "success",
|
||||
// 'fulfilled' is the status in a successful PromiseFulfilledResult
|
||||
(result) => result.status === "fulfilled" && result.value.success,
|
||||
);
|
||||
if (confirmsSucceeded.length < this.claimsToConfirmSelected.length) {
|
||||
logger.error("Error sending confirmations:", confirmResults);
|
||||
@@ -353,7 +354,7 @@ export default class QuickActionBvcBeginView extends Vue {
|
||||
undefined,
|
||||
BVC_MEETUPS_PROJECT_CLAIM_ID,
|
||||
);
|
||||
giveSucceeded = giveResult.type === "success";
|
||||
giveSucceeded = giveResult.success;
|
||||
if (!giveSucceeded) {
|
||||
logger.error("Error sending give:", giveResult);
|
||||
this.$notify(
|
||||
@@ -362,7 +363,7 @@ export default class QuickActionBvcBeginView extends Vue {
|
||||
type: "danger",
|
||||
title: "Error",
|
||||
text:
|
||||
(giveResult as ErrorResult)?.error?.userMessage ||
|
||||
(giveResult as CreateAndSubmitClaimResult)?.error ||
|
||||
"There was an error sending that give.",
|
||||
},
|
||||
5000,
|
||||
|
||||
@@ -105,7 +105,7 @@ export default class ShareMyContactInfoView extends Vue {
|
||||
group: "alert",
|
||||
type: "info",
|
||||
title: "Copied",
|
||||
text: "Your contact info was copied to the clipboard. Have them paste it in the box on their 'Contacts' screen.",
|
||||
text: "Your contact info was copied to the clipboard. Have them click on it, or paste it in the box on their 'Contacts' screen.",
|
||||
},
|
||||
5000,
|
||||
);
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
</button>
|
||||
Individual Profile
|
||||
</h1>
|
||||
<div class="text-sm text-center text-slate-500"></div>
|
||||
</div>
|
||||
|
||||
<!-- Loading Animation -->
|
||||
@@ -32,6 +33,12 @@
|
||||
<div class="text-sm">
|
||||
<font-awesome icon="user" class="fa-fw text-slate-400"></font-awesome>
|
||||
{{ didInfo(profile.issuerDid, activeDid, allMyDids, allContacts) }}
|
||||
<button title="Copy Link to Profile" @click="onCopyLinkClick()">
|
||||
<font-awesome
|
||||
icon="link"
|
||||
class="text-sm text-slate-500 ml-2 mb-1"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
<p v-if="profile.description" class="mt-4 text-slate-600">
|
||||
{{ profile.description }}
|
||||
@@ -100,6 +107,7 @@ import { Router, RouteLocationNormalizedLoaded } from "vue-router";
|
||||
import QuickNav from "../components/QuickNav.vue";
|
||||
import TopMessage from "../components/TopMessage.vue";
|
||||
import {
|
||||
APP_SERVER,
|
||||
DEFAULT_PARTNER_API_SERVER,
|
||||
NotificationIface,
|
||||
USE_DEXIE_DB,
|
||||
@@ -113,6 +121,7 @@ import { retrieveAccountDids } from "../libs/util";
|
||||
import { logger } from "../utils/logger";
|
||||
import { PlatformServiceFactory } from "@/services/PlatformServiceFactory";
|
||||
import { Settings } from "@/db/tables/settings";
|
||||
import { useClipboard } from "@vueuse/core";
|
||||
@Component({
|
||||
components: {
|
||||
LMap,
|
||||
@@ -186,6 +195,10 @@ export default class UserProfileView extends Vue {
|
||||
if (response.status === 200) {
|
||||
const result = await response.json();
|
||||
this.profile = result.data;
|
||||
if (this.profile && this.profile.rowId !== profileId) {
|
||||
// currently the server returns "rowid" with lowercase "i"; remove when that's fixed
|
||||
this.profile.rowId = profileId;
|
||||
}
|
||||
} else {
|
||||
throw new Error("Failed to load profile");
|
||||
}
|
||||
@@ -204,5 +217,22 @@ export default class UserProfileView extends Vue {
|
||||
this.isLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
onCopyLinkClick() {
|
||||
const deepLink = `${APP_SERVER}/deep-link/user-profile/${this.profile?.rowId}`;
|
||||
useClipboard()
|
||||
.copy(deepLink)
|
||||
.then(() => {
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "toast",
|
||||
title: "Copied",
|
||||
text: "A link to this profile was copied to the clipboard.",
|
||||
},
|
||||
2000,
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -22,7 +22,7 @@ export default defineConfig({
|
||||
url: 'url/',
|
||||
zlib: 'browserify-zlib',
|
||||
path: 'path-browserify',
|
||||
fs: path.resolve(__dirname, 'src/utils/node-modules/fs.js'),
|
||||
fs: false,
|
||||
tty: 'tty-browserify',
|
||||
net: false,
|
||||
dns: false,
|
||||
|
||||
Reference in New Issue
Block a user