Compare commits

..

25 Commits

Author SHA1 Message Date
f64846ae17 fix issue showing ID without name when affirming delivery of an offer 2025-12-30 18:28:54 -07:00
24b636cd2f bump version and add "-beta" for work before next release 2025-12-30 07:10:42 -07:00
faef83a664 bump to version 1.1.5 2025-12-30 07:08:26 -07:00
c992afe4d4 Merge pull request 'feat: remove the 'lock' from the giving & receiving sides' (#229) from no-locks into master
Reviewed-on: #229
2025-12-23 03:23:07 +00:00
941d93f6db fix: disallow affirming delivery of orders where giver is hidden 2025-12-20 20:20:10 -07:00
f460d6c3e2 fix: adjust playwright tests to new home-page button, and use 3 threads for fewer random failures 2025-12-20 19:59:30 -07:00
e7ca2bb791 fix: verbiage on pop-up for giving/receiving person/project 2025-12-20 19:09:47 -07:00
b864f1632d feat: remove the 'lock' from the giving & receiving sides 2025-12-20 18:58:18 -07:00
ffeac44b39 chore: bump version and add "-beta" 2025-12-18 20:29:03 -07:00
08d55519e6 chore: bump version to 1.1.4 2025-12-18 20:27:30 -07:00
bf8694fc75 fix: change the Apple group ID to something that works, ie group.app.timesafari.share 2025-12-18 20:26:02 -07:00
386b7604eb Merge pull request 'allow changing of both giver and receiver to projects or people' (#228) from giver-receiver-selectable into master
Reviewed-on: #228
2025-12-19 02:10:06 +00:00
9260892838 Revert "feat: make the new "+" appear centered on the home button"
This reverts commit fe1df9a9fb.
2025-12-18 08:56:54 -07:00
fe1df9a9fb feat: make the new "+" appear centered on the home button 2025-12-18 08:51:21 -07:00
7ef5889185 feat: make it so there's no jump when scrolling down on home feed, and the new "+" slowly appears 2025-12-18 08:46:36 -07:00
3a4cdf78d8 fix: show starred projects with most-recently-added first 2025-12-18 08:34:48 -07:00
0697b14411 refactor: change word on first button to "Thank", and center on button when it drops down 2025-12-18 08:30:07 -07:00
7aea818f01 feat: shrink & reword the button on the front, and put it at the bottom when they scroll down 2025-12-17 21:43:49 -07:00
d4a7c0dda0 feat: make the project/person selector on pop-up right-justified 2025-12-17 21:26:21 -07:00
34a7119086 feat: disallow selection of a person or project if it's already selected on the other side (giver/receiver) 2025-12-17 21:20:27 -07:00
70a0ef7ef6 feat: allow changing of both giver and receiver to projects or people 2025-12-17 21:08:09 -07:00
306e221479 feat: add starred projects at the top of the list to choose 2025-12-17 18:18:37 -07:00
4b118b0b91 Merge pull request 'web-share-target-native-implementation' (#227) from web-share-target-native-implementation into master
Reviewed-on: #227
2025-12-16 06:44:55 +00:00
38b137a86b doc: enhance and dedup some build instructions 2025-12-08 19:59:48 -07:00
dbd18bba6c allow to see the discover page without the onboarding message 2025-12-08 19:57:58 -07:00
36 changed files with 674 additions and 318 deletions

View File

@@ -175,27 +175,6 @@ cp .env.example .env.development
### Troubleshooting Quick Fixes
#### Common Issues
```bash
# Clean and rebuild
npm run clean:all
npm install
npm run build:web:dev
# Reset mobile projects
npm run clean:ios
npm run clean:android
npm run build:ios # Regenerates iOS project
npm run build:android # Regenerates Android project
# Fix Android asset issues
npm run assets:validate:android # Validates and regenerates missing Android assets
# Check environment
npm run test:web # Verifies web setup
```
#### Platform-Specific Issues
- **iOS**: Ensure Xcode and Command Line Tools are installed
@@ -385,14 +364,13 @@ rsync -azvu -e "ssh -i ~/.ssh/..." dist ubuntutest@test.timesafari.app:time-safa
(Note: The test BVC_MEETUPS_PROJECT_CLAIM_ID does not resolve as a URL because it's only in the test DB and the prod redirect won't redirect there.)
- For prod, get on the server and run the correct build:
- For prod, you can do the same with `build:web:prod` instead.
... and log onto the server:
... or log onto the server (though the build step can stay on "rendering chunks" for a long while):
- `pkgx +npm sh`
- `cd crowd-funder-for-time-pwa && git checkout master && git pull && git checkout
1.0.2 && npm install && npm run build:web:prod && cd -`
- `cd crowd-funder-for-time-pwa && git checkout master && git pull && git checkout 1.0.2 && npm install && npm run build:web:prod && cd -`
(The plain `npm run build:web:prod` uses the .env.production file.)
@@ -1142,12 +1120,13 @@ If you need to build manually or want to understand the individual steps:
- Generate certificates inside XCode.
- Right-click on App and under Signing & Capabilities set the Team.
- In the App Developer setup (eg. https://developer.apple.com/account), under Identifiers and/or "Certificates, Identifiers & Profiles"
#### Each Release
##### 0. First time (or if dependencies change)
- `pkgx +rubygems.org sh`
- `pkgx +rubygems.org zsh`
- ... and you may have to fix these, especially with pkgx:
@@ -1158,10 +1137,10 @@ export GEM_HOME=$shortened_path
export GEM_PATH=$shortened_path
```
##### 1. Bump the version in package.json for `MARKETING_VERSION`, then `grep CURRENT_PROJECT_VERSION ios/App/App.xcodeproj/project.pbxproj` and add 1 for the numbered version;
##### 1. Bump the version in package.json & CHANGELOG.md for `MARKETING_VERSION`, then `grep CURRENT_PROJECT_VERSION ios/App/App.xcodeproj/project.pbxproj` and add 1 for the numbered version here:
```bash
cd ios/App && xcrun agvtool new-version 48 && perl -p -i -e "s/MARKETING_VERSION = .*;/MARKETING_VERSION = 1.1.3;/g" App.xcodeproj/project.pbxproj && cd -
cd ios/App && xcrun agvtool new-version 50 && perl -p -i -e "s/MARKETING_VERSION = .*;/MARKETING_VERSION = 1.1.5;/g" App.xcodeproj/project.pbxproj && cd -
# Unfortunately this edits Info.plist directly.
#xcrun agvtool new-marketing-version 0.4.5
```
@@ -1319,8 +1298,8 @@ The recommended way to build for Android is using the automated build script:
##### 1. Bump the version in package.json, then update these versions & run:
```bash
perl -p -i -e 's/versionCode .*/versionCode 48/g' android/app/build.gradle
perl -p -i -e 's/versionName .*/versionName "1.1.3"/g' android/app/build.gradle
perl -p -i -e 's/versionCode .*/versionCode 50/g' android/app/build.gradle
perl -p -i -e 's/versionName .*/versionName "1.1.5"/g' android/app/build.gradle
```
##### 2. Build

View File

@@ -6,6 +6,22 @@ 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).
## [1.1.5] - 2025.12.28
### Fixed
- Incorrect prompts in give-dialog on a project or offer
## [1.1.4] - 2025.12.18
### Fixed
- Contact notes & contact methods preserved in export
### Added
- This is a target for sharing
- Switch to a project or person in give-dialog pop-up
- Starred projects onto project-choice in give-dialog pop-up
### Changed
- Front page: 1 green "Thank" button
## [1.1.3] - 2025.11.19
### Changed
- Project selection in dialogs now reaches out to server when filtering

View File

@@ -279,13 +279,11 @@ The application uses a platform-agnostic database layer with Vue mixins for serv
* `src/services/PlatformServiceFactory.ts` - Platform-specific service factory
* `src/services/AbsurdSqlDatabaseService.ts` - SQLite implementation
* `src/utils/PlatformServiceMixin.ts` - Vue mixin for database access with caching
* `src/db/` - Legacy Dexie database (migration in progress)
**Development Guidelines**:
- Always use `PlatformServiceMixin` for database operations in components
- Test with PlatformServiceMixin for new features
- Use migration tools for data transfer between systems
- Leverage mixin's ultra-concise methods: `$db()`, `$exec()`, `$one()`, `$contacts()`, `$settings()`
**Architecture Decision**: The project uses Vue mixins over Composition API composables for platform service access. See [Architecture Decisions](doc/architecture-decisions.md) for detailed rationale.
@@ -305,21 +303,9 @@ timesafari/
└── 📄 doc/README-BUILD-GUARD.md # Guard system documentation
```
## Known Issues
### Critical Vue Reactivity Bug
A critical Vue reactivity bug was discovered during ActiveDid migration testing where component properties fail to trigger template updates correctly.
**Impact**: The `newDirectOffersActivityNumber` element in HomeView.vue requires a watcher workaround to render correctly.
**Status**: Workaround implemented, investigation ongoing.
**Documentation**: See [Vue Reactivity Bug Report](doc/vue-reactivity-bug-report.md) for details.
## 🤝 Contributing
1. **Follow the Build Architecture Guard** - Update BUILDING.md when modifying build files
2. **Use the PR template** - Complete the checklist for build-related changes
3. **Test your changes** - Ensure builds work on affected platforms
4. **Document updates** - Keep BUILDING.md current and accurate

View File

@@ -31,8 +31,8 @@ android {
applicationId "app.timesafari.app"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 48
versionName "1.1.3"
versionCode 50
versionName "1.1.5"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
aaptOptions {
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.

View File

@@ -29,7 +29,7 @@ import UniformTypeIdentifiers
class ShareViewController: UIViewController {
private let appGroupIdentifier = "group.app.timesafari"
private let appGroupIdentifier = "group.app.timesafari.share"
private let sharedPhotoBase64Key = "sharedPhotoBase64"
private let sharedPhotoFileNameKey = "sharedPhotoFileName"
@@ -155,7 +155,7 @@ private func openMainApp() {
// In AppDelegate.swift
func applicationDidBecomeActive(_ application: UIApplication) {
let appGroupIdentifier = "group.app.timesafari"
let appGroupIdentifier = "group.app.timesafari.share"
guard let userDefaults = UserDefaults(suiteName: appGroupIdentifier) else {
return
}

View File

@@ -42,7 +42,7 @@ App Groups allow the Share Extension and main app to share data.
3. Click **+ Capability**
4. Select **App Groups**
5. Click **+** to add a new group
6. Enter: `group.app.timesafari`
6. Enter: `group.app.timesafari.share`
7. Ensure it's checked/enabled
### For Share Extension Target:
@@ -52,7 +52,7 @@ App Groups allow the Share Extension and main app to share data.
3. Click **+ Capability**
4. Select **App Groups**
5. Click **+** to add a new group
6. Enter: `group.app.timesafari` (same as main app)
6. Enter: `group.app.timesafari.share` (same as main app)
7. Ensure it's checked/enabled
**Important:** Both targets must use the **exact same** App Group identifier.

View File

@@ -119,7 +119,7 @@ class ShareViewController: SLComposeServiceViewController {
let base64String = imageData.base64EncodedString()
// Store in shared UserDefaults (accessible by main app)
let userDefaults = UserDefaults(suiteName: "group.app.timesafari")
let userDefaults = UserDefaults(suiteName: "group.app.timesafari.share")
userDefaults?.set(base64String, forKey: "sharedPhotoBase64")
userDefaults?.set(fileName ?? "shared-image.jpg", forKey: "sharedPhotoFileName")
userDefaults?.synchronize()
@@ -149,7 +149,7 @@ class ShareViewController: SLComposeServiceViewController {
1. In Xcode, select main app target → Signing & Capabilities
2. Add "App Groups" capability
3. Create group: `group.app.timesafari`
3. Create group: `group.app.timesafari.share`
4. Repeat for Share Extension target with same group name
#### 1.5 Update Main App to Read from App Group

View File

@@ -78,7 +78,7 @@ import Capacitor
@objc(SharedImagePlugin)
public class SharedImagePlugin: CAPPlugin {
private let appGroupIdentifier = "group.app.timesafari"
private let appGroupIdentifier = "group.app.timesafari.share"
@objc func getSharedImage(_ call: CAPPluginCall) {
// Read from App Group UserDefaults
@@ -504,7 +504,7 @@ If issues arise:
## Additional Notes
### iOS App Group:
- Current App Group ID: `group.app.timesafari`
- Current App Group ID: `group.app.timesafari.share`
- Ensure plugin has access to same App Group
- Share Extension already writes to this App Group

View File

@@ -74,7 +74,18 @@
/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
/* Begin PBXFileSystemSynchronizedRootGroup section */
C86585D62ED456DE00824752 /* TimeSafariShareExtension */ = {isa = PBXFileSystemSynchronizedRootGroup; exceptions = (C86585E32ED456DE00824752 /* PBXFileSystemSynchronizedBuildFileExceptionSet */, ); explicitFileTypes = {}; explicitFolders = (); path = TimeSafariShareExtension; sourceTree = "<group>"; };
C86585D62ED456DE00824752 /* TimeSafariShareExtension */ = {
isa = PBXFileSystemSynchronizedRootGroup;
exceptions = (
C86585E32ED456DE00824752 /* PBXFileSystemSynchronizedBuildFileExceptionSet */,
);
explicitFileTypes = {
};
explicitFolders = (
);
path = TimeSafariShareExtension;
sourceTree = "<group>";
};
/* End PBXFileSystemSynchronizedRootGroup section */
/* Begin PBXFrameworksBuildPhase section */
@@ -513,7 +524,7 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_ENTITLEMENTS = App/App.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 48;
CURRENT_PROJECT_VERSION = 50;
DEVELOPMENT_TEAM = GM3FS5JQPH;
ENABLE_APP_SANDBOX = NO;
ENABLE_USER_SCRIPT_SANDBOXING = NO;
@@ -523,7 +534,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.1.3;
MARKETING_VERSION = 1.1.5;
OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\"";
PRODUCT_BUNDLE_IDENTIFIER = app.timesafari;
PRODUCT_NAME = "$(TARGET_NAME)";
@@ -541,7 +552,7 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_ENTITLEMENTS = App/App.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 48;
CURRENT_PROJECT_VERSION = 50;
DEVELOPMENT_TEAM = GM3FS5JQPH;
ENABLE_APP_SANDBOX = NO;
ENABLE_USER_SCRIPT_SANDBOXING = NO;
@@ -551,7 +562,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.1.3;
MARKETING_VERSION = 1.1.5;
PRODUCT_BUNDLE_IDENTIFIER = app.timesafari;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "";
@@ -569,7 +580,7 @@
CLANG_ENABLE_OBJC_WEAK = YES;
CODE_SIGN_ENTITLEMENTS = TimeSafariShareExtension/TimeSafariShareExtension.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
CURRENT_PROJECT_VERSION = 50;
DEVELOPMENT_TEAM = GM3FS5JQPH;
GCC_C_LANGUAGE_STANDARD = gnu17;
GENERATE_INFOPLIST_FILE = YES;
@@ -583,7 +594,7 @@
"@executable_path/../../Frameworks",
);
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
MARKETING_VERSION = 1.0;
MARKETING_VERSION = 1.1.5;
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
MTL_FAST_MATH = YES;
PRODUCT_BUNDLE_IDENTIFIER = app.timesafari.TimeSafariShareExtension;
@@ -607,7 +618,7 @@
CLANG_ENABLE_OBJC_WEAK = YES;
CODE_SIGN_ENTITLEMENTS = TimeSafariShareExtension/TimeSafariShareExtension.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
CURRENT_PROJECT_VERSION = 50;
DEVELOPMENT_TEAM = GM3FS5JQPH;
GCC_C_LANGUAGE_STANDARD = gnu17;
GENERATE_INFOPLIST_FILE = YES;
@@ -621,7 +632,7 @@
"@executable_path/../../Frameworks",
);
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
MARKETING_VERSION = 1.0;
MARKETING_VERSION = 1.1.5;
MTL_FAST_MATH = YES;
PRODUCT_BUNDLE_IDENTIFIER = app.timesafari.TimeSafariShareExtension;
PRODUCT_NAME = "$(TARGET_NAME)";

View File

@@ -4,7 +4,7 @@
<dict>
<key>com.apple.security.application-groups</key>
<array>
<string>group.app.timesafari</string>
<string>group.app.timesafari.share</string>
</array>
</dict>
</plist>

View File

@@ -10,7 +10,7 @@
import Foundation
public class SharedImageUtility {
private static let appGroupIdentifier = "group.app.timesafari"
private static let appGroupIdentifier = "group.app.timesafari.share"
private static let sharedPhotoFileNameKey = "sharedPhotoFileName"
private static let sharedPhotoFilePathKey = "sharedPhotoFilePath"
private static let sharedPhotoReadyKey = "sharedPhotoReady"

View File

@@ -10,7 +10,7 @@ import UniformTypeIdentifiers
class ShareViewController: UIViewController {
private let appGroupIdentifier = "group.app.timesafari"
private let appGroupIdentifier = "group.app.timesafari.share"
private let sharedPhotoFileNameKey = "sharedPhotoFileName"
private let sharedPhotoFilePathKey = "sharedPhotoFilePath"
private let sharedImageFileName = "shared-image"

View File

@@ -4,7 +4,7 @@
<dict>
<key>com.apple.security.application-groups</key>
<array>
<string>group.app.timesafari</string>
<string>group.app.timesafari.share</string>
</array>
</dict>
</plist>

22
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "timesafari",
"version": "1.1.4-beta",
"version": "1.1.6-beta",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "timesafari",
"version": "1.1.4-beta",
"version": "1.1.6-beta",
"dependencies": {
"@capacitor-community/electron": "^5.0.1",
"@capacitor-community/sqlite": "6.0.2",
@@ -27,7 +27,7 @@
"@ethersproject/hdnode": "^5.7.0",
"@ethersproject/wallet": "^5.8.0",
"@fortawesome/fontawesome-svg-core": "^6.5.1",
"@fortawesome/free-brands-svg-icons": "^7.1.0",
"@fortawesome/free-brands-svg-icons": "^6.5.1",
"@fortawesome/free-regular-svg-icons": "^6.7.2",
"@fortawesome/free-solid-svg-icons": "^6.5.1",
"@fortawesome/vue-fontawesome": "^3.0.6",
@@ -6791,24 +6791,16 @@
}
},
"node_modules/@fortawesome/free-brands-svg-icons": {
"version": "7.1.0",
"resolved": "https://registry.npmjs.org/@fortawesome/free-brands-svg-icons/-/free-brands-svg-icons-7.1.0.tgz",
"integrity": "sha512-9byUd9bgNfthsZAjBl6GxOu1VPHgBuRUP9juI7ZoM98h8xNPTCTagfwUFyYscdZq4Hr7gD1azMfM9s5tIWKZZA==",
"version": "6.7.2",
"resolved": "https://registry.npmjs.org/@fortawesome/free-brands-svg-icons/-/free-brands-svg-icons-6.7.2.tgz",
"integrity": "sha512-zu0evbcRTgjKfrr77/2XX+bU+kuGfjm0LbajJHVIgBWNIDzrhpRxiCPNT8DW5AdmSsq7Mcf9D1bH0aSeSUSM+Q==",
"dependencies": {
"@fortawesome/fontawesome-common-types": "7.1.0"
"@fortawesome/fontawesome-common-types": "6.7.2"
},
"engines": {
"node": ">=6"
}
},
"node_modules/@fortawesome/free-brands-svg-icons/node_modules/@fortawesome/fontawesome-common-types": {
"version": "7.1.0",
"resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-common-types/-/fontawesome-common-types-7.1.0.tgz",
"integrity": "sha512-l/BQM7fYntsCI//du+6sEnHOP6a74UixFyOYUyz2DLMXKx+6DEhfR3F2NYGE45XH1JJuIamacb4IZs9S0ZOWLA==",
"engines": {
"node": ">=6"
}
},
"node_modules/@fortawesome/free-regular-svg-icons": {
"version": "6.7.2",
"resolved": "https://registry.npmjs.org/@fortawesome/free-regular-svg-icons/-/free-regular-svg-icons-6.7.2.tgz",

View File

@@ -1,6 +1,6 @@
{
"name": "timesafari",
"version": "1.1.4-beta",
"version": "1.1.6-beta",
"description": "Time Safari Application",
"author": {
"name": "Time Safari Team"

View File

@@ -21,7 +21,7 @@ export default defineConfig({
/* Retry on CI only */
retries: process.env.CI ? 2 : 0,
/* Opt out of parallel tests on CI. */
workers: 4,
workers: 3,
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
reporter: [
['list'],

View File

@@ -139,17 +139,65 @@ projects, and special entities with selection. * * @author Matthew Raymer */
</template>
<template v-else-if="entityType === 'projects'">
<ProjectCard
v-for="project in displayedEntities as PlanData[]"
:key="project.handleId"
:project="project"
:active-did="activeDid"
:all-my-dids="allMyDids"
:all-contacts="allContacts"
:notify="notify"
:conflict-context="conflictContext"
@project-selected="handleProjectSelected"
/>
<!-- When showing projects without search: split into recently bookmarked and rest -->
<template v-if="!searchTerm.trim()">
<!-- Recently Bookmarked Section -->
<template v-if="recentBookmarkedProjects.length > 0">
<li
class="text-xs font-semibold text-slate-500 uppercase pt-5 pb-1.5 px-2 border-b border-slate-300"
>
Recently Bookmarked
</li>
<ProjectCard
v-for="project in recentBookmarkedProjects"
:key="project.handleId"
:project="project"
:active-did="activeDid"
:all-my-dids="allMyDids"
:all-contacts="allContacts"
:conflicted="isProjectConflicted(project.handleId)"
:notify="notify"
:conflict-context="conflictContext"
@project-selected="handleProjectSelected"
/>
</template>
<!-- Rest of Projects Section -->
<li
v-if="recentBookmarkedProjects.length > 0"
class="text-xs font-semibold text-slate-500 uppercase pt-5 pb-1.5 px-2 border-b border-slate-300"
>
All Projects
</li>
<ProjectCard
v-for="project in remainingProjects"
:key="project.handleId"
:project="project"
:active-did="activeDid"
:all-my-dids="allMyDids"
:all-contacts="allContacts"
:conflicted="isProjectConflicted(project.handleId)"
:notify="notify"
:conflict-context="conflictContext"
@project-selected="handleProjectSelected"
/>
</template>
<!-- When searching: show filtered results normally -->
<template v-else>
<ProjectCard
v-for="project in displayedEntities as PlanData[]"
:key="project.handleId"
:project="project"
:active-did="activeDid"
:all-my-dids="allMyDids"
:all-contacts="allContacts"
:conflicted="isProjectConflicted(project.handleId)"
:notify="notify"
:conflict-context="conflictContext"
@project-selected="handleProjectSelected"
/>
</template>
</template>
</ul>
</template>
@@ -175,6 +223,7 @@ import { TIMEOUTS } from "@/utils/notify";
const INITIAL_BATCH_SIZE = 20;
const INCREMENT_SIZE = 20;
const RECENT_CONTACTS_COUNT = 3;
const RECENT_BOOKMARKED_PROJECTS_COUNT = 10;
/**
* EntityGrid - Unified grid layout for displaying people or projects
@@ -223,6 +272,9 @@ export default class EntityGrid extends Vue {
infiniteScrollReset?: () => void;
scrollContainer?: HTMLElement;
// Starred projects state (for showing recently bookmarked projects)
starredPlanHandleIds: string[] = [];
/**
* Array of entities to display
*
@@ -378,7 +430,8 @@ export default class EntityGrid extends Vue {
}
/**
* Get the remaining contacts sorted alphabetically (when showing contacts and not searching)
* Get all contacts sorted alphabetically (when showing contacts and not searching)
* Includes contacts shown in "Recently Added" section as well
* Uses infinite scroll to control how many are displayed
*/
get alphabeticalContacts(): Contact[] {
@@ -389,18 +442,69 @@ export default class EntityGrid extends Vue {
) {
return [];
}
// Skip the first few (recent contacts) and sort the rest alphabetically
// Sort all contacts alphabetically (including recent ones)
// Create a copy to avoid mutating the original array
const remaining = this.entities as Contact[];
const sorted = [...remaining].sort((a: Contact, b: Contact) => {
// Sort alphabetically by name, falling back to DID if name is missing
const nameA = (a.name || a.did).toLowerCase();
const nameB = (b.name || b.did).toLowerCase();
return nameA.localeCompare(nameB);
});
// Apply infinite scroll: show based on displayedCount (minus the recent contacts)
const toShow = Math.max(0, this.displayedCount - RECENT_CONTACTS_COUNT);
return sorted.slice(0, toShow);
const sorted = [...(this.entities as Contact[])].sort(
(a: Contact, b: Contact) => {
// Sort alphabetically by name, falling back to DID if name is missing
const nameA = (a.name || a.did).toLowerCase();
const nameB = (b.name || b.did).toLowerCase();
return nameA.localeCompare(nameB);
},
);
// Apply infinite scroll: show based on displayedCount
return sorted.slice(0, this.displayedCount);
}
/**
* Get the 3 most recently bookmarked projects (when showing projects and not searching)
* The starredPlanHandleIds array order represents bookmark order (newest at the end)
*/
get recentBookmarkedProjects(): PlanData[] {
if (
this.entityType !== "projects" ||
this.searchTerm.trim() ||
this.starredPlanHandleIds.length === 0
) {
return [];
}
const projects = this.entitiesToUse as PlanData[];
if (projects.length === 0) {
return [];
}
// Get the last 3 starred IDs (most recently bookmarked)
const recentStarredIds = this.starredPlanHandleIds.slice(
-RECENT_BOOKMARKED_PROJECTS_COUNT,
);
// Find projects matching those IDs, sorting with newest first
const recentProjects = recentStarredIds
.map((id) => projects.find((p) => p.handleId === id))
.filter((p): p is PlanData => p !== undefined)
.reverse();
return recentProjects;
}
/**
* Get all projects (when showing projects and not searching)
* Includes projects shown in "Recently Bookmarked" section as well
* Uses infinite scroll to control how many are displayed
*/
get remainingProjects(): PlanData[] {
if (this.entityType !== "projects" || this.searchTerm.trim()) {
return [];
}
const projects = this.entitiesToUse as PlanData[];
if (projects.length === 0) {
return [];
}
// Apply infinite scroll: show based on displayedCount
return projects.slice(0, this.displayedCount);
}
/**
@@ -455,6 +559,13 @@ export default class EntityGrid extends Vue {
return this.conflictChecker(did);
}
/**
* Check if a project handleId is conflicted
*/
isProjectConflicted(handleId: string): boolean {
return this.conflictChecker(handleId);
}
/**
* Handle person selection from PersonCard
*/
@@ -793,12 +904,11 @@ export default class EntityGrid extends Vue {
}
// People: check if more alphabetical contacts available
// Total available = recent + all alphabetical
// All contacts are shown alphabetically (recent ones appear in both sections)
if (!this.entities) {
return false;
}
const totalAvailable = RECENT_CONTACTS_COUNT + this.entities.length;
return this.displayedCount < totalAvailable;
return this.displayedCount < this.entities.length;
}
/**
@@ -810,6 +920,9 @@ export default class EntityGrid extends Vue {
const settings = await this.$accountSettings();
this.apiServer = settings.apiServer || "";
// Load starred project IDs for showing recently bookmarked projects
this.starredPlanHandleIds = settings.starredPlanHandleIds || [];
// Load projects on mount if entities prop not provided
if (!this.entities && this.apiServer) {
this.isLoadingProjects = true;
@@ -934,6 +1047,50 @@ export default class EntityGrid extends Vue {
return data;
}
/**
* Watch for changes in entityType to load projects when switching to projects
*/
@Watch("entityType")
async onEntityTypeChange(newType: "people" | "projects"): Promise<void> {
// Reset displayed count and clear search when switching types
this.displayedCount = INITIAL_BATCH_SIZE;
this.searchTerm = "";
this.filteredEntities = [];
this.searchBeforeId = undefined;
this.infiniteScrollReset?.();
// When switching to projects, load them if not provided via entities prop
if (newType === "projects" && !this.entities) {
// Ensure apiServer is loaded
if (!this.apiServer) {
const settings = await this.$accountSettings();
this.apiServer = settings.apiServer || "";
this.starredPlanHandleIds = settings.starredPlanHandleIds || [];
}
// Load projects if we have an API server
if (this.apiServer && this.allProjects.length === 0) {
this.isLoadingProjects = true;
try {
await this.fetchProjects();
} catch (error) {
logger.error(
"Error loading projects when switching to projects:",
error,
);
} finally {
this.isLoadingProjects = false;
}
}
}
// Clear project state when switching away from projects
if (newType === "people") {
this.allProjects = [];
this.loadBeforeId = undefined;
}
}
/**
* Watch for changes in search term to reset displayed count and pagination
*/
@@ -959,7 +1116,7 @@ export default class EntityGrid extends Vue {
this.displayedCount = INITIAL_BATCH_SIZE;
this.infiniteScrollReset?.();
// For projects: if entities prop is provided, clear internal state
// For projects: clear internal state if entities prop is provided
if (this.entityType === "projects" && this.entities) {
this.allProjects = [];
this.loadBeforeId = undefined;

View File

@@ -8,9 +8,19 @@ notifications for conflicted entities * - Template streamlined with computed CSS
properties * * @author Matthew Raymer */
<template>
<div id="sectionGiftedGiver">
<label class="block font-bold mb-4">
<label class="block font-bold mb-1">
{{ stepLabel }}
</label>
<!-- Toggle link for entity type selection -->
<div class="text-right mb-4">
<button
type="button"
class="text-sm text-blue-600 hover:text-blue-800 underline font-medium"
@click="handleToggleEntityType"
>
{{ toggleLinkText }}
</button>
</div>
<EntityGrid
:entity-type="shouldShowProjects ? 'projects' : 'people'"
@@ -19,7 +29,6 @@ properties * * @author Matthew Raymer */
:all-my-dids="allMyDids"
:all-contacts="allContacts"
:conflict-checker="conflictChecker"
:show-you-entity="shouldShowYouEntity"
:you-selectable="youSelectable"
:notify="notify"
:conflict-context="conflictContext"
@@ -90,10 +99,6 @@ export default class EntitySelectionStep extends Vue {
@Prop({ default: false })
showProjects!: boolean;
/** Whether this is from a project view */
@Prop({ default: false })
isFromProjectView!: boolean;
/** Array of available projects (optional - EntityGrid loads internally if not provided) */
@Prop({ required: false })
projects?: PlanData[];
@@ -160,15 +165,19 @@ export default class EntitySelectionStep extends Vue {
*/
get stepLabel(): string {
if (this.stepType === "recipient") {
return "Choose who received the gift:";
} else if (this.stepType === "giver") {
if (this.shouldShowProjects) {
return "Choose a project benefitted from:";
return "Choose recipient project";
} else {
return "Choose a person received from:";
return "Choose recipient person";
}
} else {
// this.stepType === "giver"
if (this.shouldShowProjects) {
return "Choose giving project";
} else {
return "Choose giving person";
}
}
return "Choose entity:";
}
/**
@@ -195,16 +204,6 @@ export default class EntitySelectionStep extends Vue {
return false;
}
/**
* Whether to show the "You" entity
*/
get shouldShowYouEntity(): boolean {
return (
this.stepType === "recipient" ||
(this.stepType === "giver" && this.isFromProjectView)
);
}
/**
* Whether the "You" entity is selectable
*/
@@ -212,6 +211,17 @@ export default class EntitySelectionStep extends Vue {
return !this.conflictChecker(this.activeDid);
}
/**
* Text for the toggle link
*/
get toggleLinkText(): string {
if (this.shouldShowProjects) {
return "... or choose a person instead →";
} else {
return "... or choose a project instead →";
}
}
/**
* Handle entity selection from EntityGrid
*/
@@ -222,6 +232,13 @@ export default class EntitySelectionStep extends Vue {
});
}
/**
* Handle toggle entity type button click
*/
handleToggleEntityType(): void {
this.emitToggleEntityType();
}
/**
* Handle cancel button click
*/
@@ -242,6 +259,11 @@ export default class EntitySelectionStep extends Vue {
emitCancel(): void {
// No return value needed
}
@Emit("toggle-entity-type")
emitToggleEntityType(): void {
// No return value needed
}
}
</script>

View File

@@ -1,16 +1,6 @@
/** * 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. * * Features: * - Shows entity avatar
(person or project) * - Displays entity name and role label * - Handles editable
vs locked states * - Function props for parent control over edit behavior * -
Supports both person and project entity types * - Template streamlined with
computed CSS properties * * @author Matthew Raymer */
/* EntitySummaryButton.vue - Displays selected entity with edit capability */
<template>
<component
:is="editable ? 'button' : 'div'"
:class="containerClasses"
@click="handleClick"
>
<button :class="containerClasses" @click="handleClick">
<!-- Entity Icon/Avatar -->
<div>
<template v-if="entityType === 'project'">
@@ -47,14 +37,11 @@ computed CSS properties * * @author Matthew Raymer */
</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'"
/>
<!-- Edit Icon -->
<p class="ms-auto text-sm pe-1 text-blue-500">
<font-awesome icon="pen" title="Change" />
</p>
</component>
</button>
</template>
<script lang="ts">
@@ -75,12 +62,12 @@ interface EntityData {
}
/**
* EntitySummaryButton - Displays selected entity with optional edit capability
* EntitySummaryButton - Displays selected entity with edit capability
*
* Features:
* - Shows entity avatar (person or project)
* - Displays entity name and role label
* - Handles editable vs locked states
* - Always editable - click to change entity
* - Function props for parent control over edit behavior
* - Supports both person and project entity types
* - Template streamlined with computed CSS properties
@@ -104,13 +91,9 @@ export default class EntitySummaryButton extends Vue {
@Prop({ required: true })
label!: string;
/** Whether the entity can be edited */
@Prop({ default: true })
editable!: boolean;
/**
* Function prop for handling edit requests
* Called when the button is clicked and editable, allowing parent to control edit behavior
* Called when the button is clicked, allowing parent to control edit behavior
*/
@Prop({ type: Function, default: () => {} })
onEditRequested!: (data: {
@@ -132,13 +115,6 @@ export default class EntitySummaryButton extends Vue {
return this.entity !== null && "profileImageUrl" in this.entity;
}
/**
* Computed CSS classes for the edit/lock icon
*/
get iconClasses(): string {
return this.editable ? "text-blue-500" : "text-slate-400";
}
/**
* Computed CSS classes for the entity name
*/
@@ -172,16 +148,13 @@ export default class EntitySummaryButton extends Vue {
}
/**
* Handle click event - only call function prop if editable
* Allows parent to control edit behavior and validation
* Handle click event - call function prop to allow parent to control edit behavior
*/
handleClick(): void {
if (this.editable) {
this.onEditRequested({
entityType: this.entityType,
entity: this.entity,
});
}
this.onEditRequested({
entityType: this.entityType,
entity: this.entity,
});
}
}
</script>
@@ -195,8 +168,4 @@ button {
button:hover {
background-color: #f1f5f9; /* hover:bg-slate-100 */
}
div {
cursor: default;
}
</style>

View File

@@ -16,7 +16,6 @@ control over updates and validation * * @author Matthew Raymer */
:entity="giver"
:entity-type="giverEntityType"
:label="giverLabel"
:editable="canEditGiver"
:on-edit-requested="handleEditGiver"
/>
@@ -25,7 +24,6 @@ control over updates and validation * * @author Matthew Raymer */
:entity="receiver"
:entity-type="recipientEntityType"
:label="recipientLabel"
:editable="canEditRecipient"
:on-edit-requested="handleEditRecipient"
/>
</div>
@@ -172,10 +170,6 @@ export default class GiftDetailsStep extends Vue {
@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;
@@ -277,20 +271,6 @@ export default class GiftDetailsStep extends Vue {
: "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
*/

View File

@@ -3,18 +3,18 @@
<div
class="dialog"
data-testid="gifted-dialog"
:data-recipient-entity-type="recipientEntityType"
:data-recipient-entity-type="currentRecipientEntityType"
>
<!-- Step 1: Entity Selection -->
<EntitySelectionStep
v-show="firstStep"
:step-type="stepType"
:giver-entity-type="giverEntityType"
:recipient-entity-type="recipientEntityType"
:giver-entity-type="currentGiverEntityType"
:recipient-entity-type="currentRecipientEntityType"
:show-projects="
giverEntityType === 'project' || recipientEntityType === 'project'
currentGiverEntityType === 'project' ||
currentRecipientEntityType === 'project'
"
:is-from-project-view="isFromProjectView"
:all-contacts="allContacts"
:active-did="activeDid"
:all-my-dids="allMyDids"
@@ -29,6 +29,7 @@
:offer-id="offerId"
:notify="$notify"
@entity-selected="handleEntitySelected"
@toggle-entity-type="handleToggleEntityType"
@cancel="cancel"
/>
@@ -37,13 +38,12 @@
v-show="!firstStep"
:giver="giver"
:receiver="receiver"
:giver-entity-type="giverEntityType"
:recipient-entity-type="recipientEntityType"
:giver-entity-type="currentGiverEntityType"
:recipient-entity-type="currentRecipientEntityType"
: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"
@@ -113,11 +113,10 @@ export default class GiftedDialog extends Vue {
@Prop() fromProjectId = "";
@Prop() toProjectId = "";
@Prop() isFromProjectView = false;
@Prop({ default: "person" }) giverEntityType = "person" as
@Prop({ default: "person" }) initialGiverEntityType = "person" as
| "person"
| "project";
@Prop({ default: "person" }) recipientEntityType = "person" as
@Prop({ default: "person" }) initialRecipientEntityType = "person" as
| "person"
| "project";
@@ -131,6 +130,8 @@ export default class GiftedDialog extends Vue {
description = "";
firstStep = true; // true = Step 1 (giver/recipient selection), false = Step 2 (amount/description)
giver?: libsUtil.GiverReceiverInputInfo; // undefined means no identified giver agent
currentGiverEntityType: "person" | "project" = "person"; // Mutable version (can be toggled)
currentRecipientEntityType: "person" | "project" = "person"; // Mutable version (can be toggled)
offerId = "";
prompt = "";
receiver?: libsUtil.GiverReceiverInputInfo;
@@ -142,20 +143,12 @@ export default class GiftedDialog extends Vue {
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"
this.currentGiverEntityType !== "person" ||
this.currentRecipientEntityType !== "person"
) {
return false;
}
@@ -172,22 +165,56 @@ export default class GiftedDialog extends Vue {
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
// Computed property to check if current selection would create a project conflict
get hasProjectConflict() {
// Only check for conflicts when both entities are projects
if (
this.giverEntityType !== "person" ||
this.recipientEntityType !== "person"
this.currentGiverEntityType !== "project" ||
this.currentRecipientEntityType !== "project"
) {
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;
// Check if giver and recipient are the same project
if (
this.giver?.handleId &&
this.receiver?.handleId &&
this.giver.handleId === this.receiver.handleId
) {
return true;
}
return false;
}
// Computed property to check if a contact or project would create a conflict when selected
wouldCreateConflict(identifier: string) {
// Check for person conflicts when both entities are persons
if (
this.currentGiverEntityType === "person" &&
this.currentRecipientEntityType === "person"
) {
if (this.stepType === "giver") {
// If selecting as giver, check if it conflicts with current recipient
return this.receiver?.did === identifier;
} else if (this.stepType === "recipient") {
// If selecting as recipient, check if it conflicts with current giver
return this.giver?.did === identifier;
}
}
// Check for project conflicts when both entities are projects
if (
this.currentGiverEntityType === "project" &&
this.currentRecipientEntityType === "project"
) {
if (this.stepType === "giver") {
// If selecting as giver, check if it conflicts with current recipient
return this.receiver?.handleId === identifier;
} else if (this.stepType === "recipient") {
// If selecting as recipient, check if it conflicts with current giver
return this.giver?.handleId === identifier;
}
}
return false;
@@ -211,8 +238,9 @@ export default class GiftedDialog extends Vue {
this.amountInput = amountInput || "0";
this.unitCode = unitCode || "HUR";
this.callbackOnSuccess = callbackOnSuccess;
this.firstStep = !giver;
this.stepType = "giver";
// Initialize current entity types from initial prop values
this.currentGiverEntityType = this.initialGiverEntityType;
this.currentRecipientEntityType = this.initialRecipientEntityType;
try {
const settings = await this.$accountSettings();
@@ -223,6 +251,14 @@ export default class GiftedDialog extends Vue {
const activeIdentity = await (this as any).$getActiveIdentity();
this.activeDid = activeIdentity.activeDid || "";
// Skip Step 1 if both giver and receiver are provided
const hasGiver = giver && (!!giver.did || !!giver.handleId);
const hasReceiver = receiver && (!!receiver.did || !!receiver.handleId);
this.firstStep = !hasGiver || !hasReceiver;
if (this.firstStep) {
this.stepType = giver ? "receiver" : "giver";
}
logger.debug("[GiftedDialog] Settings received:", {
activeDid: this.activeDid,
apiServer: this.apiServer,
@@ -278,6 +314,8 @@ export default class GiftedDialog extends Vue {
this.prompt = "";
this.unitCode = "HUR";
this.firstStep = true;
// Reset to initial prop values
this.currentGiverEntityType = this.initialGiverEntityType;
}
async confirm() {
@@ -315,6 +353,15 @@ export default class GiftedDialog extends Vue {
return;
}
// Check for project conflict
if (this.hasProjectConflict) {
this.safeNotify.error(
"You cannot select the same project as both giver and recipient.",
TIMEOUTS.STANDARD,
);
return;
}
this.close();
this.safeNotify.toast(
NOTIFY_GIFTED_DETAILS_RECORDING_GIVE.message,
@@ -356,8 +403,8 @@ export default class GiftedDialog extends Vue {
let providerPlanHandleId: string | undefined;
if (
this.giverEntityType === "project" &&
this.recipientEntityType === "person"
this.currentGiverEntityType === "project" &&
this.currentRecipientEntityType === "person"
) {
// Project-to-person gift
fromDid = undefined; // No person giver
@@ -365,8 +412,8 @@ export default class GiftedDialog extends Vue {
fulfillsProjectHandleId = undefined; // No project recipient
providerPlanHandleId = this.giver?.handleId; // Project giver
} else if (
this.giverEntityType === "person" &&
this.recipientEntityType === "project"
this.currentGiverEntityType === "person" &&
this.currentRecipientEntityType === "project"
) {
// Person-to-project gift
fromDid = giverDid as string; // Person giver
@@ -526,17 +573,22 @@ export default class GiftedDialog extends Vue {
return {
amountInput: this.amountInput,
description: this.description,
giverDid: this.giverEntityType === "person" ? this.giver?.did : undefined,
giverDid:
this.currentGiverEntityType === "person" ? this.giver?.did : undefined,
giverName: this.giver?.name,
offerId: this.offerId,
fulfillsProjectId:
this.recipientEntityType === "project" ? this.toProjectId : undefined,
this.currentRecipientEntityType === "project"
? this.toProjectId
: undefined,
providerProjectId:
this.giverEntityType === "project"
this.currentGiverEntityType === "project"
? this.giver?.handleId
: this.fromProjectId,
recipientDid:
this.recipientEntityType === "person" ? this.receiver?.did : undefined,
this.currentRecipientEntityType === "person"
? this.receiver?.did
: undefined,
recipientName: this.receiver?.name,
unitCode: this.unitCode,
};
@@ -596,6 +648,7 @@ export default class GiftedDialog extends Vue {
entityType: string;
currentEntity: { did: string; name: string };
}) {
// Always allow editing - go back to Step 1 to select a new entity
this.goBackToStep1(data.entityType);
}
@@ -606,6 +659,24 @@ export default class GiftedDialog extends Vue {
this.confirm();
}
/**
* Handle toggle entity type request from EntitySelectionStep
*/
handleToggleEntityType() {
// Toggle the appropriate entity type based on current step
if (this.stepType === "giver") {
this.currentGiverEntityType =
this.currentGiverEntityType === "person" ? "project" : "person";
// Clear any selected giver when toggling
this.giver = undefined;
} else if (this.stepType === "recipient") {
this.currentRecipientEntityType =
this.currentRecipientEntityType === "person" ? "project" : "person";
// Clear any selected receiver when toggling
this.receiver = undefined;
}
}
/**
* Handle amount update from GiftDetailsStep
*/

View File

@@ -1,11 +1,8 @@
/** * ProjectCard.vue - Individual project display component * * Extracted from
GiftedDialog.vue to handle project entity display * with selection states and
issuer information. * * @author Matthew Raymer */
GiftedDialog.vue to handle project entity display * with selection states,
conflict detection, and issuer information. * * @author Matthew Raymer */
<template>
<li
class="flex items-center gap-2 px-2 py-1.5 border-b border-slate-300 hover:bg-slate-50 cursor-pointer"
@click="handleClick"
>
<li :class="cardClasses" @click="handleClick">
<ProjectIcon
:entity-id="project.handleId"
:icon-size="30"
@@ -14,8 +11,8 @@ issuer information. * * @author Matthew Raymer */
/>
<div class="overflow-hidden">
<h3 class="text-sm font-semibold truncate">
{{ project.name || unnamedProject }}
<h3 :class="nameClasses">
{{ displayName }}
</h3>
<div class="text-xs text-slate-500 truncate">
@@ -33,6 +30,7 @@ import { PlanData } from "../interfaces/records";
import { Contact } from "../db/tables/contacts";
import { didInfo } from "../libs/endorserServer";
import { UNNAMED_PROJECT } from "@/constants/entities";
import { NotificationIface } from "../constants/app";
/**
* ProjectCard - Displays a project entity with selection capability
@@ -42,6 +40,8 @@ import { UNNAMED_PROJECT } from "@/constants/entities";
* - Displays project name and issuer information
* - Handles click events for selection
* - Shows issuer name using didInfo utility
* - Selection states (selectable, conflicted, disabled)
* - Warning notifications for conflicted entities
*/
@Component({
components: {
@@ -65,6 +65,18 @@ export default class ProjectCard extends Vue {
@Prop({ required: true })
allContacts!: Contact[];
/** Whether this project would create a conflict if selected */
@Prop({ default: false })
conflicted!: boolean;
/** Notification function from parent component */
@Prop()
notify?: (notification: NotificationIface, timeout?: number) => void;
/** Context for conflict messages (e.g., "giver", "recipient") */
@Prop({ default: "other party" })
conflictContext!: string;
/**
* Get the unnamed project constant
*/
@@ -72,6 +84,51 @@ export default class ProjectCard extends Vue {
return UNNAMED_PROJECT;
}
/**
* Computed CSS classes for the card
*/
get cardClasses(): string {
const baseCardClasses =
"flex items-center gap-2 px-2 py-1.5 border-b border-slate-300";
if (this.conflicted) {
return `${baseCardClasses} *:opacity-50 cursor-not-allowed`;
}
return `${baseCardClasses} cursor-pointer hover:bg-slate-50`;
}
/**
* Computed CSS classes for the project name
*/
get nameClasses(): string {
const baseNameClasses = "text-sm font-semibold truncate";
if (this.conflicted) {
return `${baseNameClasses} text-slate-500`;
}
// Add italic styling for entities without set names
if (!this.project.name) {
return `${baseNameClasses} italic text-slate-500`;
}
return baseNameClasses;
}
/**
* Computed display name for the project
*/
get displayName(): string {
// If the project has a set name, use that name
if (this.project.name) {
return this.project.name;
}
// If the project does not have a set name
return this.unnamedProject;
}
/**
* Computed display name for the project issuer
*/
@@ -85,10 +142,23 @@ export default class ProjectCard extends Vue {
}
/**
* Handle card click - emit project selection
* Handle card click - emit if not conflicted, show warning if conflicted
*/
handleClick(): void {
this.emitProjectSelected(this.project);
if (!this.conflicted) {
this.emitProjectSelected(this.project);
} else if (this.notify) {
// Show warning notification for conflicted entity
this.notify(
{
group: "alert",
type: "warning",
title: "Cannot Select",
text: `You cannot select "${this.displayName}" because it is already selected as the ${this.conflictContext}.`,
},
3000,
);
}
}
// Emit methods using @Emit decorator

View File

@@ -472,7 +472,7 @@ export function offerGiverDid(
if (giver && !serverUtil.isHiddenDid(giver)) {
return giver;
}
return giver;
return undefined;
}
/**

View File

@@ -223,21 +223,30 @@
</div>
<div class="mt-8">
<button
v-if="libsUtil.canFulfillOffer(veriClaim, isRegistered)"
class="col-span-1 block w-fit text-center text-md 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-md"
@click="openFulfillGiftDialog()"
v-if="veriClaim.claimType === 'Offer'"
class="col-span-1 block w-fit text-center text-md px-1.5 py-2 rounded-md"
:class="
libsUtil.canFulfillOffer(veriClaim, isRegistered)
? 'bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white'
: 'bg-gray-300 text-gray-500 cursor-pointer opacity-50'
"
@click="handleAffirmDeliveryClick()"
>
Affirm Delivery
<font-awesome
icon="hand-holding-heart"
class="ml-2 text-white cursor-pointer"
:class="
libsUtil.canFulfillOffer(veriClaim, isRegistered)
? 'ml-2 text-white cursor-pointer'
: 'ml-2 text-gray-500 cursor-pointer'
"
/>
</button>
</div>
<GiftedDialog
ref="customGiveDialog"
:giver-entity-type="'person'"
:recipient-entity-type="projectInfo ? 'project' : 'person'"
:initial-giver-entity-type="'person'"
:initial-recipient-entity-type="projectInfo ? 'project' : 'person'"
:to-project-id="
detailsForGive?.fulfillsPlanHandleId ||
detailsForOffer?.fulfillsPlanHandleId ||
@@ -1103,11 +1112,29 @@ export default class ClaimView extends Vue {
});
}
handleAffirmDeliveryClick() {
if (!this.isRegistered) {
this.notify.error("You must be registered to affirm delivery.");
return;
}
if (!libsUtil.canFulfillOffer(this.veriClaim, this.isRegistered)) {
this.notify.error(
"You cannot see all the information in this offer so you cannot affirm delivery.",
);
return;
}
this.openFulfillGiftDialog();
}
openFulfillGiftDialog() {
const giverDid = libsUtil.offerGiverDid(
this.veriClaim as GenericCredWrapper<OfferClaim>,
);
// Look up the giver contact to get their name
const giverContact = serverUtil.contactForDid(giverDid, this.allContacts);
const giver: libsUtil.GiverReceiverInputInfo = {
did: libsUtil.offerGiverDid(
this.veriClaim as GenericCredWrapper<OfferClaim>,
),
did: giverDid,
name: giverContact?.name || giverDid, // Use contact name if available, otherwise DID
};
// Determine recipient based on whether it's a project or person

View File

@@ -105,11 +105,10 @@
<GiftedDialog
ref="giftedDialog"
:giver-entity-type="giverEntityType"
:recipient-entity-type="recipientEntityType"
:initial-giver-entity-type="giverEntityType"
:initial-recipient-entity-type="recipientEntityType"
:from-project-id="fromProjectId"
:to-project-id="toProjectId"
:is-from-project-view="isFromProjectView"
:hide-show-all="true"
/>
</section>
@@ -165,7 +164,6 @@ export default class ContactGiftingView extends Vue {
fromProjectId = "";
toProjectId = "";
showProjects = false;
isFromProjectView = false;
offerId = "";
async created() {
@@ -217,8 +215,6 @@ export default class ContactGiftingView extends Vue {
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";
this.offerId = (this.$route.query["offerId"] as string) || "";
// eslint-disable-next-line @typescript-eslint/no-explicit-any

View File

@@ -120,8 +120,8 @@
<GiftedDialog
ref="customGivenDialog"
:giver-entity-type="'person'"
:recipient-entity-type="'person'"
:initial-giver-entity-type="'person'"
:initial-recipient-entity-type="'person'"
/>
<OfferDialog ref="customOfferDialog" />
<ContactNameDialog ref="contactNameDialog" />
@@ -1088,7 +1088,7 @@ export default class ContactsView extends Vue {
{
group: "modal",
type: "confirm",
title: "Delete",
title: "Confirm First?",
text: message,
onNo: async () => {
this.showGiftedDialog(giverDid, recipientDid);

View File

@@ -465,6 +465,8 @@ export default class DiscoverView extends Vue {
async mounted() {
this.searchTerms = this.$route.query["searchText"]?.toString() || "";
const hideOnboarding =
this.$route.query["hideOnboarding"]?.toString() === "true";
const searchPeople = !!this.$route.query["searchPeople"];
@@ -483,7 +485,7 @@ export default class DiscoverView extends Vue {
this.allMyDids = await retrieveAccountDids();
if (!settings.finishedOnboarding) {
if (!settings.finishedOnboarding && !hideOnboarding) {
(this.$refs.onboardingDialog as OnboardingDialog).open(
OnboardPage.Discover,
);

View File

@@ -111,7 +111,34 @@ Raymer * @version 1.0.0 */
<!-- Record Quick-Action -->
<div class="mb-6">
<div class="flex gap-2 items-center mb-2">
<h2 class="font-bold">Record something given by:</h2>
<!-- Thank button - always visible and unchanged -->
<button
type="button"
class="text-center text-base uppercase bg-gradient-to-b from-green-400 to-green-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white flex items-center justify-center gap-2 px-4 py-3 rounded-full"
@click="openPersonDialog()"
>
<font-awesome icon="plus" />
<span>Thank</span>
</button>
<!-- Plus button - appears when scrolled, positioned over house-chimney icon -->
<transition
enter-active-class="transition-all duration-1000 ease-out"
leave-active-class="transition-all duration-1000 ease-in"
enter-from-class="scale-0"
enter-to-class="scale-100"
leave-from-class="scale-100"
leave-to-class="scale-0"
>
<button
v-if="isScrolled"
type="button"
class="fixed bottom-10 p-4 w-14 h-14 z-50 text-center bg-gradient-to-b from-green-400 to-green-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white rounded-full flex items-center justify-center"
:style="getButtonPosition()"
@click="openPersonDialog()"
>
<font-awesome icon="plus" />
</button>
</transition>
<button
class="block ms-auto text-sm 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-1.5 rounded-full"
@click="openGiftedPrompts()"
@@ -122,25 +149,6 @@ Raymer * @version 1.0.0 */
/>
</button>
</div>
<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="openPersonDialog()"
>
<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>
</div>
</div>
</div>
@@ -148,8 +156,8 @@ Raymer * @version 1.0.0 */
<GiftedDialog
ref="giftedDialog"
:giver-entity-type="showProjectsDialog ? 'project' : 'person'"
:recipient-entity-type="'person'"
:initial-giver-entity-type="'person'"
:initial-recipient-entity-type="'person'"
/>
<GiftedPrompts ref="giftedPrompts" />
<FeedFilters ref="feedFilters" />
@@ -446,7 +454,8 @@ export default class HomeView extends Vue {
userAgentInfo = new UAParser(); // see https://docs.uaparser.js.org/v2/api/ua-parser-js/get-os.html
selectedImage = "";
isImageViewerOpen = false;
showProjectsDialog = false;
isScrolled = false;
scrollHandler?: () => void;
/**
* CRITICAL VUE REACTIVITY BUG WORKAROUND
@@ -547,11 +556,44 @@ export default class HomeView extends Vue {
this.numNewOffersToUser + this.numNewOffersToUserProjects > 0,
},
});
// Add scroll listener for button collapse
// Note: Scrolling happens on #app element, not window (see tailwind.css)
const appElement = document.getElementById("app");
const scrollElement = appElement || window;
this.scrollHandler = () => {
const scrollTop = appElement
? appElement.scrollTop
: window.pageYOffset || document.documentElement.scrollTop || 0;
const shouldBeScrolled = scrollTop > 100;
if (this.isScrolled !== shouldBeScrolled) {
this.isScrolled = shouldBeScrolled;
}
};
// Set initial state
this.scrollHandler();
// Listen on scroll element (prefer #app, fallback to window)
scrollElement.addEventListener("scroll", this.scrollHandler, {
passive: true,
});
} catch (err: unknown) {
this.handleError(err);
}
}
/**
* Cleanup scroll listener on component unmount
*/
beforeUnmount() {
if (this.scrollHandler) {
const appElement = document.getElementById("app");
const scrollElement = appElement || window;
scrollElement.removeEventListener("scroll", this.scrollHandler);
}
}
/**
* Watch for changes in the current activeDid
* Reload settings when user switches identities
@@ -1811,17 +1853,19 @@ export default class HomeView extends Vue {
* - this.activeDid
*
* @param giver Optional contact info for giver
* @param description Optional gift description
* @param prompt Optional gift prompt
*/
openDialog(giver?: GiverReceiverInputInfo, prompt?: string) {
// Determine the giver entity based on DID logic
const giverEntity = this.createGiverEntity(giver);
// In HomeView, "You" is the default recipient but it's not locked
// User can still change it in Step 1 if they want
(this.$refs.giftedDialog as GiftedDialog).open(
giverEntity,
{
did: this.activeDid,
name: "You", // In HomeView, we always use "You" as the giver
name: "You",
} as GiverReceiverInputInfo,
undefined,
prompt,
@@ -1919,15 +1963,9 @@ export default class HomeView extends Vue {
}
openPersonDialog(giver?: GiverReceiverInputInfo, prompt?: string) {
this.showProjectsDialog = false;
this.openDialog(giver, prompt);
}
openProjectDialog() {
this.showProjectsDialog = true;
(this.$refs.giftedDialog as GiftedDialog).open();
}
/**
* Computed property for registration status
*
@@ -1937,5 +1975,39 @@ export default class HomeView extends Vue {
get isUserRegistered() {
return this.isRegistered;
}
/**
* Calculates the horizontal position for the button to align with house button center
*/
getButtonPosition() {
const quickNav = document.getElementById("QuickNav");
if (!quickNav) {
return { left: "1.5rem" }; // Fallback to left-6
}
const navList = quickNav.querySelector("ul");
if (!navList) {
return { left: "1.5rem" };
}
// Get the first nav item (house button)
const firstItem = navList.querySelector("li:first-child");
if (!firstItem) {
return { left: "1.5rem" };
}
const itemRect = firstItem.getBoundingClientRect();
const buttonWidth = 56; // w-14 = 3.5rem = 56px
// Calculate center of house button
const houseButtonCenter = itemRect.left + itemRect.width / 2;
// Position button so its center aligns with house button center
const buttonLeft = houseButtonCenter - buttonWidth / 2;
return {
left: `${buttonLeft}px`,
};
}
}
</script>

View File

@@ -331,7 +331,7 @@ export default class OfferDetailsView extends Vue {
get recipientAssignmentLabel() {
return this.recipientDid
? `This is offered to ${this.recipientName}`
: "No recipient was chosen.";
: "No named individual recipient was chosen.";
}
/**

View File

@@ -238,10 +238,9 @@
<GiftedDialog
ref="giveDialogToThis"
:giver-entity-type="'person'"
:recipient-entity-type="'project'"
:initial-giver-entity-type="'person'"
:initial-recipient-entity-type="'project'"
:to-project-id="projectId"
:is-from-project-view="true"
/>
<!-- Offers & Gifts to & from this -->
@@ -521,10 +520,9 @@
</div>
<GiftedDialog
ref="giveDialogFromThis"
:giver-entity-type="'project'"
:recipient-entity-type="'person'"
:initial-giver-entity-type="'project'"
:initial-recipient-entity-type="'person'"
:from-project-id="projectId"
:is-from-project-view="true"
/>
<h3 class="text-lg font-bold leading-tight mb-3">
@@ -1299,9 +1297,15 @@ export default class ProjectViewView extends Vue {
claim: offer.fullClaim,
issuer: offer.offeredByDid,
};
const giverDid = libsUtil.offerGiverDid(offerClaimCred);
// Look up the giver contact to get their name
const giverContact = serverUtil.contactForDid(giverDid, this.allContacts);
const giver: libsUtil.GiverReceiverInputInfo = {
did: libsUtil.offerGiverDid(offerClaimCred),
did: giverDid,
name: giverContact?.name || giverDid, // Use contact name if available, otherwise DID
};
(this.$refs.giveDialogToThis as GiftedDialog).open(
giver,
{

View File

@@ -58,7 +58,8 @@
<div
v-if="
profile.issuerDid !== activeDid && // only show neighbors if they're not current user
profile.issuerDid !== neighbors?.[0]?.did // and they're not directly connected (since there's no in-between)
profile.issuerDid !== neighbors?.[0]?.did // and they're not directly connected
// (which we know because there is no neighbor in-between them)
"
class="mt-6"
>

View File

@@ -282,9 +282,9 @@ test('Check User 0 can register a random person', async ({ page }) => {
} catch (error) {
console.log('Could not force close dialog, continuing...');
}
// Wait for Person button to be ready - simplified approach
await page.waitForSelector('button:has-text("Person")', { timeout: 10000 });
await page.getByRole('button', { name: 'Person' }).click();
// Wait for Thank button to be ready - simplified approach
await page.waitForSelector('button:has-text("Thank")', { timeout: 10000 });
await page.getByRole('button', { name: 'Thank' }).click();
await page.getByRole('listitem').filter({ hasText: UNNAMED_ENTITY_NAME }).locator('svg').click();
await page.getByPlaceholder('What was given').fill('Gave me access!');
await page.getByRole('button', { name: 'Sign & Send' }).click();

View File

@@ -107,7 +107,7 @@ test('Record something given', async ({ page }) => {
return !document.querySelector('.dialog-overlay');
}, { timeout: 5000 });
await page.getByRole('button', { name: 'Person' }).click();
await page.getByRole('button', { name: 'Thank' }).click();
await page.getByRole('listitem').filter({ hasText: UNNAMED_ENTITY_NAME }).locator('svg').click();
await page.getByPlaceholder('What was given').fill(finalTitle);
await page.getByRole('spinbutton').fill(randomNonZeroNumber.toString());

View File

@@ -116,7 +116,7 @@ test('Record 9 new gifts', async ({ page }) => {
if (i === 0) {
await page.getByTestId('closeOnboardingAndFinish').click();
}
await page.getByRole('button', { name: 'Person' }).click();
await page.getByRole('button', { name: 'Thank' }).click();
await page.getByRole('listitem').filter({ hasText: UNNAMED_ENTITY_NAME }).locator('svg').click();
await page.getByPlaceholder('What was given').fill(finalTitles[i]);
await page.getByRole('spinbutton').fill(finalNumbers[i].toString());

View File

@@ -1,7 +1,8 @@
import { test, expect, Page } from '@playwright/test';
import { importUser } from './testUtils';
async function testProjectGive(page: Page, selector: string) {
async function testProjectGive(page: Page, isToProject: boolean) {
const selector = isToProject ? 'gives-to' : 'gives-from';
// Generate a random string of a few characters
const randomString = Math.random().toString(36).substring(2, 6);
@@ -42,9 +43,9 @@ async function testProjectGive(page: Page, selector: string) {
}
test('Record a give to a project', async ({ page }) => {
await testProjectGive(page, 'gives-to');
await testProjectGive(page, true);
});
test('Record a give from a project', async ({ page }) => {
await testProjectGive(page, 'gives-from');
await testProjectGive(page, false);
});

View File

@@ -117,7 +117,7 @@ test('Add contact, record gift, confirm gift', async ({ page }) => {
// Confirm that home shows contact in "Record Something…"
await page.goto('./');
await page.getByTestId('closeOnboardingAndFinish').click();
await page.getByRole('button', { name: 'Person' }).click();
await page.getByRole('button', { name: 'Thank' }).click();
await expect(page.locator('#sectionGiftedGiver').getByRole('listitem').filter({ hasText: contactName })).toBeVisible();
// Record something given by new contact