chore: Remove package-lock.json and enhance financials API with user summaries

This commit includes the following changes:

- Deleted the `package-lock.json` file to streamline dependency management.
- Updated the `financials.py` endpoint to return a comprehensive user financial summary, including net balance, total group spending, debts, and credits.
- Enhanced the `expense.py` CRUD operations to handle enum values and improve error handling during expense deletion.
- Introduced new schemas in `financials.py` for user financial summaries and debt/credit tracking.
- Refactored the costs service to improve group balance summary calculations.

These changes aim to improve the application's financial tracking capabilities and maintain cleaner dependency management.
This commit is contained in:
mohamad 2025-06-28 21:37:26 +02:00
parent 229f6b7b1c
commit d6c5e6fcfd
58 changed files with 14792 additions and 849 deletions

View File

@ -5,6 +5,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.orm import joinedload from sqlalchemy.orm import joinedload
from typing import List as PyList, Optional, Sequence, Union from typing import List as PyList, Optional, Sequence, Union
from decimal import Decimal
from app.database import get_transactional_session from app.database import get_transactional_session
from app.auth import current_active_user from app.auth import current_active_user
@ -23,7 +24,7 @@ from app.schemas.expense import (
SettlementCreate, SettlementPublic, SettlementCreate, SettlementPublic,
ExpenseUpdate, SettlementUpdate ExpenseUpdate, SettlementUpdate
) )
from app.schemas.financials import FinancialActivityResponse from app.schemas.financials import FinancialActivityResponse, UserFinancialSummary, DebtCredit, SummaryUser
from app.schemas.settlement_activity import SettlementActivityCreate, SettlementActivityPublic # Added from app.schemas.settlement_activity import SettlementActivityCreate, SettlementActivityPublic # Added
from app.crud import expense as crud_expense from app.crud import expense as crud_expense
from app.crud import settlement as crud_settlement from app.crud import settlement as crud_settlement
@ -35,7 +36,8 @@ from app.core.exceptions import (
InvalidOperationError, GroupPermissionError, ListPermissionError, InvalidOperationError, GroupPermissionError, ListPermissionError,
ItemNotFoundError, GroupMembershipError, OverpaymentError, FinancialConflictError ItemNotFoundError, GroupMembershipError, OverpaymentError, FinancialConflictError
) )
from app.services import financials_service from app.services import financials_service, costs_service
from app.schemas.cost import GroupBalanceSummary
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
router = APIRouter() router = APIRouter()
@ -298,7 +300,7 @@ async def delete_expense_record(
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="User cannot delete this expense (must be payer or group owner)") raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="User cannot delete this expense (must be payer or group owner)")
try: try:
await crud_expense.delete_expense(db=db, expense_db=expense_db, expected_version=expected_version) await crud_expense.delete_expense(db=db, expense_db=expense_db, current_user_id=current_user.id, expected_version=expected_version)
logger.info(f"Expense ID {expense_id} deleted successfully.") logger.info(f"Expense ID {expense_id} deleted successfully.")
# No need to return content on 204 # No need to return content on 204
except InvalidOperationError as e: except InvalidOperationError as e:
@ -688,3 +690,121 @@ async def get_user_financial_activity(
# The service returns a mix of ExpenseModel and SettlementModel objects. # The service returns a mix of ExpenseModel and SettlementModel objects.
# We need to wrap it in our response schema. Pydantic will handle the Union type. # We need to wrap it in our response schema. Pydantic will handle the Union type.
return FinancialActivityResponse(activities=activities) return FinancialActivityResponse(activities=activities)
@router.get(
"/summary/group/{group_id}",
response_model=UserFinancialSummary,
summary="Get Financial Summary for a Specific Group (alias)",
tags=["Financials", "Groups"],
)
async def alias_group_balance_summary(
group_id: int,
db: AsyncSession = Depends(get_transactional_session),
current_user: UserModel = Depends(current_active_user),
):
"""Returns a financial summary formatted for the frontend for a single group, leveraging
the existing costs_service balance logic."""
group_summary = await costs_service.get_group_balance_summary_logic(
db=db, group_id=group_id, current_user_id=current_user.id
)
net_balance = Decimal("0.00")
debts: PyList[DebtCredit] = []
credits: PyList[DebtCredit] = []
# Find the current user's balance within the group
for ub in group_summary.user_balances:
if ub.user_id == current_user.id:
net_balance = ub.net_balance
break
# Translate suggested settlements
if group_summary.suggested_settlements:
for s in group_summary.suggested_settlements:
if s.from_user_id == current_user.id:
debts.append(
DebtCredit(
user=SummaryUser(id=s.to_user_id, name=s.to_user_identifier),
amount=float(s.amount),
)
)
elif s.to_user_id == current_user.id:
credits.append(
DebtCredit(
user=SummaryUser(id=s.from_user_id, name=s.from_user_identifier),
amount=float(s.amount),
)
)
return UserFinancialSummary(
net_balance=float(net_balance.quantize(Decimal("0.01"))),
total_group_spending=float(group_summary.overall_total_expenses.quantize(Decimal("0.01"))),
debts=debts,
credits=credits,
currency="USD",
)
@router.get(
"/summary/user",
response_model=UserFinancialSummary,
summary="Get Consolidated Financial Summary for Current User",
tags=["Financials"],
)
async def get_current_user_financial_summary(
db: AsyncSession = Depends(get_transactional_session),
current_user: UserModel = Depends(current_active_user),
):
"""Aggregates the user's financial position across all groups they belong to and returns
a concise summary structure expected by the frontend (net balance, debts, credits, etc.)."""
# 1. Retrieve groups where the user is a member
groups_result = await db.execute(
select(GroupModel).join(UserGroupModel).where(UserGroupModel.user_id == current_user.id)
)
groups: Sequence[GroupModel] = groups_result.scalars().all()
net_balance = Decimal("0.00")
total_group_spending = Decimal("0.00")
debts: PyList[DebtCredit] = []
credits: PyList[DebtCredit] = []
# 2. Iterate through each group and accumulate balances & settlements
for group in groups:
group_summary = await costs_service.get_group_balance_summary_logic(
db=db, group_id=group.id, current_user_id=current_user.id
)
total_group_spending += group_summary.overall_total_expenses
# Find current user's balance within the group
for ub in group_summary.user_balances:
if ub.user_id == current_user.id:
net_balance += ub.net_balance
break
# Translate suggested_settlements into debts/credits relative to current user
if group_summary.suggested_settlements:
for s in group_summary.suggested_settlements:
if s.from_user_id == current_user.id:
# Current user owes another household member
debts.append(
DebtCredit(
user=SummaryUser(id=s.to_user_id, name=s.to_user_identifier),
amount=float(s.amount),
)
)
elif s.to_user_id == current_user.id:
# Another member owes current user
credits.append(
DebtCredit(
user=SummaryUser(id=s.from_user_id, name=s.from_user_identifier),
amount=float(s.amount),
)
)
return UserFinancialSummary(
net_balance=float(net_balance.quantize(Decimal("0.01"))),
total_group_spending=float(total_group_spending.quantize(Decimal("0.01"))),
debts=debts,
credits=credits,
currency="USD",
)

View File

@ -678,6 +678,8 @@ async def update_expense(db: AsyncSession, expense_db: ExpenseModel, expense_in:
for k, v in before_state.items(): for k, v in before_state.items():
if isinstance(v, (datetime, Decimal)): if isinstance(v, (datetime, Decimal)):
before_state[k] = str(v) before_state[k] = str(v)
elif hasattr(v, 'value'): # Handle enums
before_state[k] = v.value
update_data = expense_in.dict(exclude_unset=True, exclude={"version"}) update_data = expense_in.dict(exclude_unset=True, exclude={"version"})
@ -705,6 +707,8 @@ async def update_expense(db: AsyncSession, expense_db: ExpenseModel, expense_in:
for k, v in after_state.items(): for k, v in after_state.items():
if isinstance(v, (datetime, Decimal)): if isinstance(v, (datetime, Decimal)):
after_state[k] = str(v) after_state[k] = str(v)
elif hasattr(v, 'value'): # Handle enums
after_state[k] = v.value
await create_financial_audit_log( await create_financial_audit_log(
db=db, db=db,
@ -740,6 +744,8 @@ async def delete_expense(db: AsyncSession, expense_db: ExpenseModel, current_use
for k, v in details.items(): for k, v in details.items():
if isinstance(v, (datetime, Decimal)): if isinstance(v, (datetime, Decimal)):
details[k] = str(v) details[k] = str(v)
elif hasattr(v, 'value'): # Handle enums
details[k] = v.value
expense_id_for_log = expense_db.id expense_id_for_log = expense_db.id
@ -751,11 +757,34 @@ async def delete_expense(db: AsyncSession, expense_db: ExpenseModel, current_use
details=details details=details
) )
# Manually delete related records in correct order to avoid foreign key constraint issues
from app.models import ExpenseSplit as ExpenseSplitModel, SettlementActivity as SettlementActivityModel
# First, get all the splits for this expense
splits_result = await db.execute(
select(ExpenseSplitModel).where(ExpenseSplitModel.expense_id == expense_db.id)
)
splits_to_delete = splits_result.scalars().all()
# Then, delete all settlement activities that reference these splits
for split in splits_to_delete:
settlement_activities_result = await db.execute(
select(SettlementActivityModel).where(SettlementActivityModel.expense_split_id == split.id)
)
settlement_activities_to_delete = settlement_activities_result.scalars().all()
for settlement_activity in settlement_activities_to_delete:
await db.delete(settlement_activity)
# Now we can safely delete the splits
for split in splits_to_delete:
await db.delete(split)
await db.delete(expense_db) await db.delete(expense_db)
await db.flush() await db.flush()
except IntegrityError as e: except IntegrityError as e:
logger.error(f"Database integrity error during expense deletion for ID {expense_id_for_log}: {str(e)}", exc_info=True) logger.error(f"Database integrity error during expense deletion for ID {expense_id_for_log}: {str(e)}", exc_info=True)
raise DatabaseIntegrityError(f"Failed to delete expense ID {expense_id_for_log} due to database integrity issue.") from e raise DatabaseIntegrityError() from e
except SQLAlchemyError as e: except SQLAlchemyError as e:
logger.error(f"Database transaction error during expense deletion for ID {expense_id_for_log}: {str(e)}", exc_info=True) logger.error(f"Database transaction error during expense deletion for ID {expense_id_for_log}: {str(e)}", exc_info=True)
raise DatabaseTransactionError(f"Failed to delete expense ID {expense_id_for_log} due to a database transaction error.") from e raise DatabaseTransactionError(f"Failed to delete expense ID {expense_id_for_log} due to a database transaction error.") from e

View File

@ -1,9 +1,38 @@
from pydantic import BaseModel from pydantic import BaseModel, Field
from typing import Union, List from typing import Union, List
from .expense import ExpensePublic, SettlementPublic from .expense import ExpensePublic, SettlementPublic
from decimal import Decimal
class FinancialActivityResponse(BaseModel): class FinancialActivityResponse(BaseModel):
activities: List[Union[ExpensePublic, SettlementPublic]] activities: List[Union[ExpensePublic, SettlementPublic]]
class Config: class Config:
orm_mode = True orm_mode = True
class SummaryUser(BaseModel):
"""Lightweight representation of a user for summary responses."""
id: int
name: str
class Config:
orm_mode = True
class DebtCredit(BaseModel):
"""Represents a debt or credit entry in the user financial summary."""
user: SummaryUser
amount: float
class Config:
orm_mode = True
class UserFinancialSummary(BaseModel):
"""Aggregated financial summary for the authenticated user across all groups."""
net_balance: float = 0.0
total_group_spending: float = 0.0
debts: List[DebtCredit] = Field(default_factory=list)
credits: List[DebtCredit] = Field(default_factory=list)
currency: str = "USD"
class Config:
orm_mode = True

View File

@ -262,7 +262,7 @@ async def get_group_balance_summary_logic(
settlements_result = await db.execute( settlements_result = await db.execute(
select(SettlementModel).where(SettlementModel.group_id == group_id) select(SettlementModel).where(SettlementModel.group_id == group_id)
.options(selectinload(SettlementModel.paid_by_user), selectinload(SettlementModel.paid_to_user)) .options(selectinload(SettlementModel.payer), selectinload(SettlementModel.payee))
) )
settlements = settlements_result.scalars().all() settlements = settlements_result.scalars().all()
@ -330,8 +330,8 @@ async def get_group_balance_summary_logic(
final_user_balances.sort(key=lambda x: x.user_identifier) final_user_balances.sort(key=lambda x: x.user_identifier)
suggested_settlements = calculate_suggested_settlements(final_user_balances) suggested_settlements = calculate_suggested_settlements(final_user_balances)
overall_total_expenses = sum(expense.total_amount for expense in expenses) overall_total_expenses = sum((expense.total_amount for expense in expenses), start=Decimal("0.00"))
overall_total_settlements = sum(settlement.amount for settlement in settlements) overall_total_settlements = sum((settlement.amount for settlement in settlements), start=Decimal("0.00"))
return GroupBalanceSummary( return GroupBalanceSummary(
group_id=db_group.id, group_id=db_group.id,

View File

@ -23,7 +23,12 @@ npm install
### Compile and Hot-Reload for Development ### Compile and Hot-Reload for Development
```sh ```sh
npm run dev # Frontend only
npm run dev:fe
# Start both frontend & backend in two terminals
npm run dev:be
npm run dev:fe
``` ```
### Type-Check, Compile and Minify for Production ### Type-Check, Compile and Minify for Production

337
fe/package-lock.json generated
View File

@ -12,10 +12,15 @@
"@iconify/vue": "^4.1.1", "@iconify/vue": "^4.1.1",
"@sentry/tracing": "^7.120.3", "@sentry/tracing": "^7.120.3",
"@sentry/vue": "^7.120.3", "@sentry/vue": "^7.120.3",
"@types/dom-speech-recognition": "^0.0.6",
"@types/qrcode": "^1.5.5",
"@vueuse/core": "^13.1.0", "@vueuse/core": "^13.1.0",
"axios": "^1.9.0", "axios": "^1.9.0",
"date-fns": "^4.1.0", "date-fns": "^4.1.0",
"idb-keyval": "^6.2.2",
"mock-socket": "^9.3.1",
"pinia": "^3.0.2", "pinia": "^3.0.2",
"qrcode": "^1.5.4",
"qs": "^6.14.0", "qs": "^6.14.0",
"vue": "^3.5.13", "vue": "^3.5.13",
"vue-i18n": "^9.9.1", "vue-i18n": "^9.9.1",
@ -4103,6 +4108,12 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/dom-speech-recognition": {
"version": "0.0.6",
"resolved": "https://registry.npmjs.org/@types/dom-speech-recognition/-/dom-speech-recognition-0.0.6.tgz",
"integrity": "sha512-o7pAVq9UQPJL5RDjO1f/fcpfFHdgiMnR4PoIU2N/ZQrYOS3C5rzdOJMsrpqeBCbii2EE9mERXgqspQqPDdPahw==",
"license": "MIT"
},
"node_modules/@types/estree": { "node_modules/@types/estree": {
"version": "1.0.7", "version": "1.0.7",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.7.tgz", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.7.tgz",
@ -4133,12 +4144,20 @@
"version": "22.15.17", "version": "22.15.17",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.15.17.tgz", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.15.17.tgz",
"integrity": "sha512-wIX2aSZL5FE+MR0JlvF87BNVrtFWf6AE6rxSE9X7OwnVvoyCQjpzSRJ+M87se/4QCkCiebQAqrJ0y6fwIyi7nw==", "integrity": "sha512-wIX2aSZL5FE+MR0JlvF87BNVrtFWf6AE6rxSE9X7OwnVvoyCQjpzSRJ+M87se/4QCkCiebQAqrJ0y6fwIyi7nw==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"undici-types": "~6.21.0" "undici-types": "~6.21.0"
} }
}, },
"node_modules/@types/qrcode": {
"version": "1.5.5",
"resolved": "https://registry.npmjs.org/@types/qrcode/-/qrcode-1.5.5.tgz",
"integrity": "sha512-CdfBi/e3Qk+3Z/fXYShipBT13OJ2fDO2Q2w5CIP5anLTLIndQG9z6P1cnm+8zCWSpm5dnxMFd/uREtb0EXuQzg==",
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/qs": { "node_modules/@types/qs": {
"version": "6.14.0", "version": "6.14.0",
"resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.14.0.tgz", "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.14.0.tgz",
@ -5041,7 +5060,6 @@
"version": "4.3.0", "version": "4.3.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"color-convert": "^2.0.1" "color-convert": "^2.0.1"
@ -5601,6 +5619,15 @@
"node": ">=6" "node": ">=6"
} }
}, },
"node_modules/camelcase": {
"version": "5.3.1",
"resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz",
"integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/camelcase-css": { "node_modules/camelcase-css": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz",
@ -5692,11 +5719,76 @@
"url": "https://paulmillr.com/funding/" "url": "https://paulmillr.com/funding/"
} }
}, },
"node_modules/cliui": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz",
"integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==",
"license": "ISC",
"dependencies": {
"string-width": "^4.2.0",
"strip-ansi": "^6.0.0",
"wrap-ansi": "^6.2.0"
}
},
"node_modules/cliui/node_modules/ansi-regex": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/cliui/node_modules/emoji-regex": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
"license": "MIT"
},
"node_modules/cliui/node_modules/string-width": {
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
"license": "MIT",
"dependencies": {
"emoji-regex": "^8.0.0",
"is-fullwidth-code-point": "^3.0.0",
"strip-ansi": "^6.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/cliui/node_modules/strip-ansi": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
"license": "MIT",
"dependencies": {
"ansi-regex": "^5.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/cliui/node_modules/wrap-ansi": {
"version": "6.2.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz",
"integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==",
"license": "MIT",
"dependencies": {
"ansi-styles": "^4.0.0",
"string-width": "^4.1.0",
"strip-ansi": "^6.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/color-convert": { "node_modules/color-convert": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"color-name": "~1.1.4" "color-name": "~1.1.4"
@ -5709,7 +5801,6 @@
"version": "1.1.4", "version": "1.1.4",
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/combined-stream": { "node_modules/combined-stream": {
@ -6030,6 +6121,15 @@
} }
} }
}, },
"node_modules/decamelize": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz",
"integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/decimal.js": { "node_modules/decimal.js": {
"version": "10.5.0", "version": "10.5.0",
"resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.5.0.tgz", "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.5.0.tgz",
@ -6194,6 +6294,12 @@
"dev": true, "dev": true,
"license": "Apache-2.0" "license": "Apache-2.0"
}, },
"node_modules/dijkstrajs": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz",
"integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==",
"license": "MIT"
},
"node_modules/dlv": { "node_modules/dlv": {
"version": "1.1.3", "version": "1.1.3",
"resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz",
@ -7497,6 +7603,15 @@
"node": ">=6.9.0" "node": ">=6.9.0"
} }
}, },
"node_modules/get-caller-file": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
"license": "ISC",
"engines": {
"node": "6.* || 8.* || >= 10.*"
}
},
"node_modules/get-intrinsic": { "node_modules/get-intrinsic": {
"version": "1.3.0", "version": "1.3.0",
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
@ -7860,6 +7975,12 @@
"integrity": "sha512-gchesWBzyvGHRO9W8tzUWFDycow5gwjvFKfyV9FF32Y7F50yZMp7mP+T2mJIWFx49zicqyC4uefHM17o6xKIVQ==", "integrity": "sha512-gchesWBzyvGHRO9W8tzUWFDycow5gwjvFKfyV9FF32Y7F50yZMp7mP+T2mJIWFx49zicqyC4uefHM17o6xKIVQ==",
"license": "ISC" "license": "ISC"
}, },
"node_modules/idb-keyval": {
"version": "6.2.2",
"resolved": "https://registry.npmjs.org/idb-keyval/-/idb-keyval-6.2.2.tgz",
"integrity": "sha512-yjD9nARJ/jb1g+CvD0tlhUHOrJ9Sy0P8T9MF3YaLlHnSRpwPfpTX0XIvpmw3gAJUmEu3FiICLBDPXVwyEvrleg==",
"license": "Apache-2.0"
},
"node_modules/ignore": { "node_modules/ignore": {
"version": "5.3.2", "version": "5.3.2",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
@ -8166,7 +8287,6 @@
"version": "3.0.0", "version": "3.0.0",
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=8" "node": ">=8"
@ -9154,6 +9274,15 @@
"ufo": "^1.5.4" "ufo": "^1.5.4"
} }
}, },
"node_modules/mock-socket": {
"version": "9.3.1",
"resolved": "https://registry.npmjs.org/mock-socket/-/mock-socket-9.3.1.tgz",
"integrity": "sha512-qxBgB7Qa2sEQgHFjj0dSigq7fX4k6Saisd5Nelwp2q8mlbAFh5dHV9JTTlF8viYJLSSWgMCZFUom8PJcMNBoJw==",
"license": "MIT",
"engines": {
"node": ">= 8"
}
},
"node_modules/mrmime": { "node_modules/mrmime": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz", "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz",
@ -9602,6 +9731,15 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/p-try": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz",
"integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/package-json-from-dist": { "node_modules/package-json-from-dist": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz",
@ -9669,7 +9807,6 @@
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
"integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=8" "node": ">=8"
@ -9878,6 +10015,15 @@
"node": ">=18" "node": ">=18"
} }
}, },
"node_modules/pngjs": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz",
"integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==",
"license": "MIT",
"engines": {
"node": ">=10.13.0"
}
},
"node_modules/possible-typed-array-names": { "node_modules/possible-typed-array-names": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz",
@ -10183,6 +10329,23 @@
"node": ">=6" "node": ">=6"
} }
}, },
"node_modules/qrcode": {
"version": "1.5.4",
"resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz",
"integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==",
"license": "MIT",
"dependencies": {
"dijkstrajs": "^1.0.1",
"pngjs": "^5.0.0",
"yargs": "^15.3.1"
},
"bin": {
"qrcode": "bin/qrcode"
},
"engines": {
"node": ">=10.13.0"
}
},
"node_modules/qs": { "node_modules/qs": {
"version": "6.14.0", "version": "6.14.0",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz", "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz",
@ -10449,6 +10612,15 @@
"node": ">=6" "node": ">=6"
} }
}, },
"node_modules/require-directory": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
"integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/require-from-string": { "node_modules/require-from-string": {
"version": "2.0.2", "version": "2.0.2",
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
@ -10459,6 +10631,12 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/require-main-filename": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz",
"integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==",
"license": "ISC"
},
"node_modules/resolve": { "node_modules/resolve": {
"version": "1.22.10", "version": "1.22.10",
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.10.tgz", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.10.tgz",
@ -10787,6 +10965,12 @@
"node": ">= 18" "node": ">= 18"
} }
}, },
"node_modules/set-blocking": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz",
"integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==",
"license": "ISC"
},
"node_modules/set-function-length": { "node_modules/set-function-length": {
"version": "1.2.2", "version": "1.2.2",
"resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz",
@ -12109,7 +12293,6 @@
"version": "6.21.0", "version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/unicode-canonical-property-names-ecmascript": { "node_modules/unicode-canonical-property-names-ecmascript": {
@ -12925,6 +13108,12 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/which-module": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz",
"integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==",
"license": "ISC"
},
"node_modules/which-typed-array": { "node_modules/which-typed-array": {
"version": "1.1.19", "version": "1.1.19",
"resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.19.tgz", "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.19.tgz",
@ -13575,6 +13764,12 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/y18n": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz",
"integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==",
"license": "ISC"
},
"node_modules/yallist": { "node_modules/yallist": {
"version": "3.1.1", "version": "3.1.1",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
@ -13611,6 +13806,134 @@
"url": "https://github.com/sponsors/ota-meshi" "url": "https://github.com/sponsors/ota-meshi"
} }
}, },
"node_modules/yargs": {
"version": "15.4.1",
"resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz",
"integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==",
"license": "MIT",
"dependencies": {
"cliui": "^6.0.0",
"decamelize": "^1.2.0",
"find-up": "^4.1.0",
"get-caller-file": "^2.0.1",
"require-directory": "^2.1.1",
"require-main-filename": "^2.0.0",
"set-blocking": "^2.0.0",
"string-width": "^4.2.0",
"which-module": "^2.0.0",
"y18n": "^4.0.0",
"yargs-parser": "^18.1.2"
},
"engines": {
"node": ">=8"
}
},
"node_modules/yargs-parser": {
"version": "18.1.3",
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz",
"integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==",
"license": "ISC",
"dependencies": {
"camelcase": "^5.0.0",
"decamelize": "^1.2.0"
},
"engines": {
"node": ">=6"
}
},
"node_modules/yargs/node_modules/ansi-regex": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/yargs/node_modules/emoji-regex": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
"license": "MIT"
},
"node_modules/yargs/node_modules/find-up": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz",
"integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==",
"license": "MIT",
"dependencies": {
"locate-path": "^5.0.0",
"path-exists": "^4.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/yargs/node_modules/locate-path": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz",
"integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==",
"license": "MIT",
"dependencies": {
"p-locate": "^4.1.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/yargs/node_modules/p-limit": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
"integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==",
"license": "MIT",
"dependencies": {
"p-try": "^2.0.0"
},
"engines": {
"node": ">=6"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/yargs/node_modules/p-locate": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz",
"integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==",
"license": "MIT",
"dependencies": {
"p-limit": "^2.2.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/yargs/node_modules/string-width": {
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
"license": "MIT",
"dependencies": {
"emoji-regex": "^8.0.0",
"is-fullwidth-code-point": "^3.0.0",
"strip-ansi": "^6.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/yargs/node_modules/strip-ansi": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
"license": "MIT",
"dependencies": {
"ansi-regex": "^5.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/yocto-queue": { "node_modules/yocto-queue": {
"version": "0.1.0", "version": "0.1.0",
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",

View File

@ -16,17 +16,24 @@
"lint": "run-s lint:*", "lint": "run-s lint:*",
"format": "prettier --write src/", "format": "prettier --write src/",
"storybook": "storybook dev -p 6006", "storybook": "storybook dev -p 6006",
"build-storybook": "storybook build" "build-storybook": "storybook build",
"dev:fe": "vite",
"dev:be": "cd ../be && uvicorn app.main:app --reload"
}, },
"dependencies": { "dependencies": {
"@headlessui/vue": "^1.7.23", "@headlessui/vue": "^1.7.23",
"@iconify/vue": "^4.1.1", "@iconify/vue": "^4.1.1",
"@sentry/tracing": "^7.120.3", "@sentry/tracing": "^7.120.3",
"@sentry/vue": "^7.120.3", "@sentry/vue": "^7.120.3",
"@types/dom-speech-recognition": "^0.0.6",
"@types/qrcode": "^1.5.5",
"@vueuse/core": "^13.1.0", "@vueuse/core": "^13.1.0",
"axios": "^1.9.0", "axios": "^1.9.0",
"date-fns": "^4.1.0", "date-fns": "^4.1.0",
"idb-keyval": "^6.2.2",
"mock-socket": "^9.3.1",
"pinia": "^3.0.2", "pinia": "^3.0.2",
"qrcode": "^1.5.4",
"qs": "^6.14.0", "qs": "^6.14.0",
"vue": "^3.5.13", "vue": "^3.5.13",
"vue-i18n": "^9.9.1", "vue-i18n": "^9.9.1",

View File

@ -1,10 +1,12 @@
<template> <template>
<router-view /> <router-view />
<NotificationDisplay /> <!-- For custom notifications --> <NotificationDisplay />
<ConflictResolutionDialog />
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import NotificationDisplay from '@/components/global/NotificationDisplay.vue'; import NotificationDisplay from '@/components/global/NotificationDisplay.vue';
import ConflictResolutionDialog from '@/components/global/ConflictResolutionDialog.vue';
// Potentially initialize offline store or other global listeners here if needed // Potentially initialize offline store or other global listeners here if needed
// import { useOfflineStore } from './stores/offline'; // import { useOfflineStore } from './stores/offline';
// const offlineStore = useOfflineStore(); // const offlineStore = useOfflineStore();

View File

@ -1,97 +1,563 @@
<template> <template>
<TransitionRoot appear :show="open" as="template"> <Dialog v-model="isOpen" :unmount="false">
<Dialog as="div" class="relative z-50" @close="close"> <div class="auth-sheet">
<TransitionChild as="template" enter="ease-out duration-300" enter-from="opacity-0" enter-to="opacity-100" <!-- Header -->
leave="ease-in duration-200" leave-from="opacity-100" leave-to="opacity-0"> <div class="auth-header">
<div class="fixed inset-0 bg-black/25" /> <div class="brand">
</TransitionChild> <h1 class="brand-title">mitlist</h1>
<p class="brand-subtitle">Smart household management</p>
</div>
<div class="fixed inset-0 overflow-y-auto"> <button v-if="allowClose" @click="closeSheet" class="close-button" aria-label="Close authentication">
<div class="flex min-h-full items-center justify-center p-4 text-center"> <span class="material-icons">close</span>
<TransitionChild as="template" enter="ease-out duration-300" enter-from="opacity-0 scale-95"
enter-to="opacity-100 scale-100" leave="ease-in duration-200" leave-from="opacity-100 scale-100"
leave-to="opacity-0 scale-95">
<DialogPanel
class="w-full max-w-md transform overflow-hidden rounded-2xl bg-white dark:bg-dark p-6 text-left align-middle shadow-xl transition-all">
<DialogTitle class="text-lg font-medium leading-6 text-gray-900 dark:text-light mb-4">Sign
in</DialogTitle>
<div v-if="step === 'email'" class="space-y-4">
<p class="text-sm text-gray-600 dark:text-gray-300">Enter your email and we'll send you
a magic link.</p>
<form @submit.prevent="sendLink" class="space-y-4">
<input v-model="email" type="email" placeholder="you@example.com"
class="w-full rounded border border-gray-300 px-3 py-2 focus:outline-none focus:ring-primary focus:border-primary text-sm dark:bg-neutral-800 dark:border-neutral-600"
required />
<button type="submit"
class="w-full inline-flex justify-center rounded bg-primary text-white px-4 py-2 text-sm font-medium hover:bg-primary/90 disabled:opacity-50"
:disabled="loading">
<span v-if="loading" class="animate-pulse">Sending</span>
<span v-else>Send Magic Link</span>
</button> </button>
</div>
<!-- Mode Toggle -->
<div class="mode-toggle">
<div class="mode-buttons">
<button :class="['mode-btn', { active: mode === 'login' }]" @click="setMode('login')">
Sign In
</button>
<button :class="['mode-btn', { active: mode === 'register' }]" @click="setMode('register')">
Get Started
</button>
</div>
</div>
<!-- Content -->
<div class="auth-content">
<!-- Welcome Message -->
<div class="welcome-section">
<h2 class="welcome-title">
{{ mode === 'login' ? 'Welcome back!' : 'Welcome to mitlist!' }}
</h2>
<p class="welcome-text">
{{
mode === 'login'
? 'Sign in to your household dashboard'
: 'Join thousands of organized households'
}}
</p>
</div>
<!-- Demo Mode Banner -->
<div v-if="showDemoMode" class="demo-banner">
<div class="demo-content">
<span class="material-icons demo-icon">play_circle</span>
<div>
<h3 class="demo-title">Try Demo Mode</h3>
<p class="demo-text">Explore without creating an account</p>
</div>
</div>
<Button variant="soft" color="primary" size="sm" @click="startDemo">
Try Demo
</Button>
</div>
<!-- Social Auth (Primary) -->
<div class="social-auth-section">
<div class="social-buttons">
<Button v-for="provider in socialProviders" :key="provider.id" variant="outline" color="neutral"
size="lg" fullWidth :loading="authState.loading === provider.id"
@click="handleSocialAuth(provider)" class="social-btn">
<template #icon-left>
<img :src="provider.icon" :alt="provider.name" class="social-icon" />
</template>
{{ mode === 'login' ? 'Continue' : 'Sign up' }} with {{ provider.name }}
</Button>
</div>
</div>
<!-- Divider -->
<div class="divider">
<span class="divider-text">or continue with email</span>
</div>
<!-- Email Form -->
<form @submit.prevent="handleEmailAuth" class="email-form">
<div class="form-field">
<Input v-model="authState.email" type="email" placeholder="Enter your email"
:error="authState.errors.email" :loading="authState.loading === 'email'"
@input="validateEmail" @blur="validateEmail" required autofocus />
</div>
<div v-if="emailMode === 'password'" class="form-field">
<Input v-model="authState.password" type="password" placeholder="Enter your password"
:error="authState.errors.password" @input="validatePassword" @blur="validatePassword"
required />
</div>
<div v-if="mode === 'register' && emailMode === 'password'" class="form-field">
<Input v-model="authState.name" type="text" placeholder="Your name"
:error="authState.errors.name" @input="validateName" @blur="validateName" required />
</div>
<!-- Magic Link Info -->
<div v-if="emailMode === 'magic'" class="magic-info">
<div class="magic-icon">
<span class="material-icons">magic_button</span>
</div>
<div class="magic-text">
<h4>Sign in with magic link</h4>
<p>We'll send a secure sign-in link to your email</p>
</div>
</div>
<!-- Submit Button -->
<Button type="submit" variant="solid" color="primary" size="lg" fullWidth
:loading="authState.loading === 'email'" :disabled="!isFormValid">
{{
emailMode === 'magic'
? 'Send Magic Link'
: mode === 'login'
? 'Sign In'
: 'Create Account'
}}
</Button>
<!-- Auth Mode Toggle -->
<div class="auth-toggle">
<button type="button" @click="toggleEmailMode" class="toggle-link">
{{
emailMode === 'magic'
? 'Use password instead'
: 'Use magic link instead'
}}
</button>
</div>
</form> </form>
<div class="relative py-2">
<div class="absolute inset-0 flex items-center">
<div class="w-full border-t border-gray-200 dark:border-neutral-700" />
</div>
<div class="relative flex justify-center text-xs uppercase"><span
class="bg-white dark:bg-dark px-2 text-gray-500">or</span></div>
</div>
<SocialLoginButtons />
</div>
<div v-else-if="step === 'sent'" class="space-y-4"> <!-- Footer -->
<p class="text-sm text-gray-700 dark:text-gray-200">We've sent a link to <strong>{{ <div class="auth-footer">
email }}</strong>. Check your inbox and click the link to sign in.</p> <p class="terms-text">
<button @click="close" class="mt-2 text-sm text-primary hover:underline">Close</button> By continuing, you agree to our
<a href="/terms" class="terms-link">Terms of Service</a>
and
<a href="/privacy" class="terms-link">Privacy Policy</a>
</p>
</div> </div>
</DialogPanel>
</TransitionChild>
</div> </div>
</div> </div>
</Dialog> </Dialog>
</TransitionRoot>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, defineExpose, watch } from 'vue' import { ref, computed, watch, onMounted } from 'vue'
import { TransitionRoot, TransitionChild, Dialog, DialogPanel, DialogTitle } from '@headlessui/vue' import { useRouter } from 'vue-router'
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth'
import SocialLoginButtons from '@/components/SocialLoginButtons.vue'
import { useNotificationStore } from '@/stores/notifications' import { useNotificationStore } from '@/stores/notifications'
import Dialog from '@/components/ui/Dialog.vue'
import Button from '@/components/ui/Button.vue'
import Input from '@/components/ui/Input.vue'
const open = ref(false) interface SocialProvider {
const email = ref('') id: string
const loading = ref(false) name: string
const step = ref<'email' | 'sent'>('email') icon: string
url: string
}
interface AuthState {
email: string
password: string
name: string
loading: string | null
errors: {
email?: string
password?: string
name?: string
general?: string
}
}
const props = defineProps<{
modelValue: boolean
allowClose?: boolean
showDemoMode?: boolean
initialMode?: 'login' | 'register'
}>()
const emit = defineEmits<{
'update:modelValue': [value: boolean]
'demo-started': []
'auth-success': [user: any]
}>()
const router = useRouter()
const authStore = useAuthStore() const authStore = useAuthStore()
const notify = useNotificationStore() const notificationStore = useNotificationStore()
function show() { const isOpen = computed({
open.value = true get: () => props.modelValue,
step.value = 'email' set: (value) => emit('update:modelValue', value)
email.value = '' })
const mode = ref<'login' | 'register'>(props.initialMode || 'login')
const emailMode = ref<'password' | 'magic'>('magic') // Default to magic link
const authState = ref<AuthState>({
email: '',
password: '',
name: '',
loading: null,
errors: {}
})
// Social providers in order of preference
const socialProviders = ref<SocialProvider[]>([
{
id: 'google',
name: 'Google',
icon: 'https://developers.google.com/identity/images/g-logo.png',
url: '/api/auth/google'
},
{
id: 'apple',
name: 'Apple',
icon: 'https://appleid.apple.com/favicon.ico',
url: '/api/auth/apple'
} }
function close() { ])
open.value = false
// Auto-detect returning users
onMounted(() => {
const lastEmail = localStorage.getItem('last-email')
if (lastEmail) {
authState.value.email = lastEmail
mode.value = 'login'
}
})
const setMode = (newMode: 'login' | 'register') => {
mode.value = newMode
clearErrors()
} }
async function sendLink() { const closeSheet = () => {
loading.value = true if (props.allowClose) {
isOpen.value = false
}
}
const startDemo = () => {
emit('demo-started')
notificationStore.addNotification({
type: 'success',
message: 'Demo mode started! Explore all features.',
})
closeSheet()
}
const toggleEmailMode = () => {
emailMode.value = emailMode.value === 'magic' ? 'password' : 'magic'
clearErrors()
}
const clearErrors = () => {
authState.value.errors = {}
}
// Validation functions
const validateEmail = () => {
const email = authState.value.email
if (!email) {
authState.value.errors.email = 'Email is required'
return false
}
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
if (!emailRegex.test(email)) {
authState.value.errors.email = 'Please enter a valid email'
return false
}
delete authState.value.errors.email
return true
}
const validatePassword = () => {
const password = authState.value.password
if (emailMode.value === 'magic') return true
if (!password) {
authState.value.errors.password = 'Password is required'
return false
}
if (password.length < 8) {
authState.value.errors.password = 'Password must be at least 8 characters'
return false
}
delete authState.value.errors.password
return true
}
const validateName = () => {
const name = authState.value.name
if (mode.value === 'login' || emailMode.value === 'magic') return true
if (!name || name.trim().length < 2) {
authState.value.errors.name = 'Please enter your name'
return false
}
delete authState.value.errors.name
return true
}
const isFormValid = computed(() => {
if (emailMode.value === 'magic') {
return validateEmail()
}
if (mode.value === 'register') {
return validateEmail() && validatePassword() && validateName()
}
return validateEmail() && validatePassword()
})
// Auth handlers
const handleSocialAuth = async (provider: SocialProvider) => {
authState.value.loading = provider.id
try { try {
await authStore.requestMagicLink(email.value) // In a real app, this would redirect to OAuth provider
step.value = 'sent' window.location.href = provider.url
notify.addNotification({ message: 'Magic link sent!', type: 'success' }) } catch (error) {
} catch (e: any) { authState.value.errors.general = `Failed to sign in with ${provider.name}`
notify.addNotification({ message: e?.response?.data?.detail || 'Failed to send link', type: 'error' }) authState.value.loading = null
} finally {
loading.value = false
} }
} }
defineExpose({ show }) const handleEmailAuth = async () => {
if (!isFormValid.value) return
authState.value.loading = 'email'
clearErrors()
try {
if (emailMode.value === 'magic') {
// Send magic link
await authStore.sendMagicLink(authState.value.email)
notificationStore.addNotification({
type: 'success',
message: 'Magic link sent! Check your email.',
})
closeSheet()
} else {
// Regular email/password auth
if (mode.value === 'login') {
const user = await authStore.login({
email: authState.value.email,
password: authState.value.password
})
localStorage.setItem('last-email', authState.value.email)
emit('auth-success', user)
closeSheet()
} else {
const user = await authStore.register({
email: authState.value.email,
password: authState.value.password,
name: authState.value.name
})
localStorage.setItem('last-email', authState.value.email)
emit('auth-success', user)
closeSheet()
}
}
} catch (error: any) {
authState.value.errors.general = error.message || 'Authentication failed'
} finally {
authState.value.loading = null
}
}
// Clear form when mode changes
watch(mode, () => {
authState.value.password = ''
authState.value.name = ''
clearErrors()
})
</script> </script>
<style scoped></style> <style scoped>
.auth-sheet {
@apply max-w-md mx-auto bg-white rounded-2xl shadow-floating p-6;
@apply max-h-[90vh] overflow-y-auto;
}
.auth-header {
@apply flex items-start justify-between mb-6;
}
.brand-title {
@apply text-2xl font-bold text-primary-600 mb-1;
}
.brand-subtitle {
@apply text-sm text-neutral-600;
}
.close-button {
@apply p-2 rounded-full text-neutral-400 hover:text-neutral-600 hover:bg-neutral-100;
@apply transition-colors duration-micro;
}
.mode-toggle {
@apply mb-6;
}
.mode-buttons {
@apply flex bg-neutral-100 rounded-lg p-1;
}
.mode-btn {
@apply flex-1 py-2 px-4 text-sm font-medium rounded-md transition-all duration-micro;
@apply text-neutral-600 hover:text-neutral-900;
}
.mode-btn.active {
@apply bg-white text-primary-600 shadow-soft;
}
.welcome-section {
@apply text-center mb-6;
}
.welcome-title {
@apply text-xl font-semibold text-neutral-900 mb-2;
}
.welcome-text {
@apply text-sm text-neutral-600;
}
.demo-banner {
@apply flex items-center justify-between p-4 bg-primary-50 rounded-lg border border-primary-200 mb-6;
}
.demo-content {
@apply flex items-center gap-3;
}
.demo-icon {
@apply text-primary-500 text-xl;
}
.demo-title {
@apply text-sm font-medium text-primary-900;
}
.demo-text {
@apply text-xs text-primary-700;
}
.social-auth-section {
@apply mb-6;
}
.social-buttons {
@apply space-y-3;
}
.social-btn {
@apply justify-start;
}
.social-icon {
@apply w-5 h-5;
}
.divider {
@apply relative flex items-center justify-center mb-6;
}
.divider::before {
@apply absolute inset-0 flex items-center;
content: "";
}
.divider::before {
@apply border-t border-neutral-200;
}
.divider-text {
@apply bg-white px-4 text-xs text-neutral-500 font-medium;
}
.email-form {
@apply space-y-4;
}
.form-field {
@apply space-y-1;
}
.magic-info {
@apply flex items-start gap-3 p-4 bg-blue-50 rounded-lg border border-blue-200;
}
.magic-icon {
@apply text-blue-500 text-xl;
}
.magic-text h4 {
@apply text-sm font-medium text-blue-900 mb-1;
}
.magic-text p {
@apply text-xs text-blue-700;
}
.auth-toggle {
@apply text-center;
}
.toggle-link {
@apply text-sm text-primary-600 hover:text-primary-700 font-medium;
@apply transition-colors duration-micro;
}
.auth-footer {
@apply mt-6 pt-6 border-t border-neutral-200;
}
.terms-text {
@apply text-xs text-neutral-500 text-center;
}
.terms-link {
@apply text-primary-600 hover:text-primary-700;
@apply transition-colors duration-micro;
}
/* Mobile optimizations */
@media (max-width: 640px) {
.auth-sheet {
@apply m-4 max-h-[95vh];
}
}
/* Animation for form transitions */
.form-field {
animation: fade-in 200ms ease-out;
}
@keyframes fade-in {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
</style>

View File

@ -0,0 +1,410 @@
<template>
<Card class="chore-card" :class="getCardClasses()">
<!-- Chore Header -->
<div class="chore-header">
<div class="chore-info">
<div class="chore-priority">
<span class="material-icons" :class="getPriorityIconClasses()">
{{ getPriorityIcon() }}
</span>
</div>
<div class="chore-details">
<h3 class="chore-title">{{ chore.name }}</h3>
<p v-if="chore.description" class="chore-description">
{{ chore.description }}
</p>
<div class="chore-meta">
<span class="chore-due-date" :class="getDueDateClasses()">
{{ getDueDateText() }}
</span>
<div class="chore-points">
<span class="material-icons text-warning-500">star</span>
<span class="points-text">{{ chore.points }}</span>
</div>
</div>
</div>
</div>
<!-- Assignment Info -->
<div v-if="chore.assignedTo && !chore.isAvailable" class="assignment-info">
<div class="assigned-avatar">
<span class="avatar-initials">
{{ getAssignedUser()?.name?.charAt(0) || getAssignedUser()?.full_name?.charAt(0) || '?' }}
</span>
</div>
</div>
</div>
<!-- Progress Indicator (for recurring chores) -->
<div v-if="chore.estimatedDuration" class="progress-section">
<div class="progress-info">
<span class="material-icons text-neutral-400">schedule</span>
<span class="duration-text">{{ formatDuration(chore.estimatedDuration) }}</span>
</div>
</div>
<!-- Action Buttons -->
<div class="chore-actions">
<!-- Complete Action (for assigned chores) -->
<Button v-if="canCompleteChore()" variant="solid" color="success" size="sm" fullWidth
@click="handleComplete" :loading="isCompleting">
<template #icon-left>
<span class="material-icons">check_circle</span>
</template>
Mark Complete
</Button>
<!-- Claim Action (for available chores) -->
<Button v-else-if="canClaimChore()" variant="outline" color="primary" size="sm" fullWidth
@click="handleClaim" :loading="isClaiming">
<template #icon-left>
<span class="material-icons">volunteer_activism</span>
</template>
Claim This Chore
</Button>
<!-- View Details Action (for completed/unassigned) -->
<Button v-else variant="ghost" color="neutral" size="sm" fullWidth @click="handleViewDetails">
<template #icon-left>
<span class="material-icons">info</span>
</template>
{{ category === 'archive' ? 'View Details' : 'Details' }}
</Button>
</div>
<!-- Completion Celebration (for archive) -->
<div v-if="category === 'archive' && chore.completedBy" class="completion-info">
<div class="completion-badge">
<span class="material-icons text-success-500">celebration</span>
<span class="completion-text">
Completed by {{ getCompletedByUser()?.name || getCompletedByUser()?.full_name || 'Someone' }}
</span>
<span class="completion-date">
{{ formatCompletionDate() }}
</span>
</div>
</div>
</Card>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { useAuthStore } from '@/stores/auth'
import { useGroupStore } from '@/stores/groupStore'
import Card from '@/components/ui/Card.vue'
import Button from '@/components/ui/Button.vue'
import type { UserPublic } from '@/types/user'
interface Chore {
id: string
name: string
description?: string
dueDate: Date
priority: 'urgent' | 'high' | 'medium' | 'low'
assignedTo?: string
points: number
isComplete: boolean
completedAt?: Date
completedBy?: string
isOverdue: boolean
isAvailable: boolean
estimatedDuration?: number
}
const props = defineProps<{
chore: Chore
category: 'priority' | 'upcoming' | 'available' | 'archive'
}>()
const emit = defineEmits<{
complete: [chore: Chore]
claim: [chore: Chore]
'view-details': [chore: Chore]
}>()
const authStore = useAuthStore()
const groupStore = useGroupStore()
const isCompleting = ref(false)
const isClaiming = ref(false)
// User lookup functions
const getAssignedUser = (): UserPublic | null => {
if (!props.chore.assignedTo || !groupStore.currentGroup) return null
return groupStore.currentGroup.members.find((member: UserPublic) => member.id.toString() === props.chore.assignedTo) || null
}
const getCompletedByUser = (): UserPublic | null => {
if (!props.chore.completedBy || !groupStore.currentGroup) return null
return groupStore.currentGroup.members.find((member: UserPublic) => member.id.toString() === props.chore.completedBy) || null
}
// Permission checks
const canCompleteChore = (): boolean => {
if (props.chore.isComplete) return false
if (props.chore.isAvailable) return false
if (!props.chore.assignedTo) return false
return props.chore.assignedTo === authStore.user?.id
}
const canClaimChore = (): boolean => {
if (props.chore.isComplete) return false
return props.chore.isAvailable
}
// Styling functions
const getCardClasses = () => {
const classes = ['transition-all duration-micro hover:shadow-medium']
if (props.chore.isOverdue) {
classes.push('border-error-200 bg-error-50')
} else if (props.category === 'priority') {
classes.push('border-warning-200 bg-warning-50')
} else if (props.category === 'archive') {
classes.push('border-success-200 bg-success-50')
}
return classes.join(' ')
}
const getPriorityIcon = () => {
const icons = {
urgent: 'priority_high',
high: 'trending_up',
medium: 'remove',
low: 'trending_down'
}
return icons[props.chore.priority] || 'remove'
}
const getPriorityIconClasses = () => {
const classes = {
urgent: 'text-error-500',
high: 'text-warning-500',
medium: 'text-primary-500',
low: 'text-neutral-500'
}
return classes[props.chore.priority] || 'text-neutral-500'
}
const getDueDateClasses = () => {
if (props.chore.isOverdue) {
return 'text-error-600 font-medium'
} else if (props.category === 'priority') {
return 'text-warning-600 font-medium'
}
return 'text-neutral-600'
}
const getDueDateText = () => {
const dueDate = new Date(props.chore.dueDate)
const now = new Date()
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate())
const tomorrow = new Date(today.getTime() + 24 * 60 * 60 * 1000)
if (props.chore.isOverdue) {
const daysDiff = Math.floor((now.getTime() - dueDate.getTime()) / (24 * 60 * 60 * 1000))
if (daysDiff === 0) return 'Due today'
if (daysDiff === 1) return 'Due yesterday'
return `Overdue by ${daysDiff} days`
}
if (dueDate.toDateString() === today.toDateString()) {
return 'Due today'
} else if (dueDate.toDateString() === tomorrow.toDateString()) {
return 'Due tomorrow'
} else {
return `Due ${dueDate.toLocaleDateString()}`
}
}
const formatDuration = (minutes: number): string => {
if (minutes < 60) {
return `${minutes}min`
}
const hours = Math.floor(minutes / 60)
const remainingMinutes = minutes % 60
if (remainingMinutes === 0) {
return `${hours}h`
}
return `${hours}h ${remainingMinutes}m`
}
const formatCompletionDate = (): string => {
if (!props.chore.completedAt) return ''
const completedDate = new Date(props.chore.completedAt)
const now = new Date()
const diffInDays = Math.floor((now.getTime() - completedDate.getTime()) / (24 * 60 * 60 * 1000))
if (diffInDays === 0) return 'today'
if (diffInDays === 1) return 'yesterday'
if (diffInDays < 7) return `${diffInDays} days ago`
return completedDate.toLocaleDateString()
}
// Event handlers
const handleComplete = async () => {
isCompleting.value = true
try {
emit('complete', props.chore)
} finally {
isCompleting.value = false
}
}
const handleClaim = async () => {
isClaiming.value = true
try {
emit('claim', props.chore)
} finally {
isClaiming.value = false
}
}
const handleViewDetails = () => {
emit('view-details', props.chore)
}
</script>
<style scoped>
.chore-card {
@apply p-4 space-y-4;
@apply border border-neutral-200 bg-white rounded-lg;
}
.chore-header {
@apply flex items-start justify-between gap-4;
}
.chore-info {
@apply flex-1 min-w-0 flex gap-3;
}
.chore-priority {
@apply flex-shrink-0 mt-1;
}
.chore-details {
@apply flex-1 min-w-0;
}
.chore-title {
@apply text-base font-medium text-neutral-900 mb-1;
@apply line-clamp-2;
}
.chore-description {
@apply text-sm text-neutral-600 mb-2;
@apply line-clamp-2;
}
.chore-meta {
@apply flex items-center justify-between gap-2;
}
.chore-due-date {
@apply text-xs;
}
.chore-points {
@apply flex items-center gap-1;
}
.points-text {
@apply text-xs font-medium text-warning-600;
}
.assignment-info {
@apply flex-shrink-0;
}
.assigned-avatar {
@apply w-8 h-8 rounded-full overflow-hidden bg-primary-100;
@apply flex items-center justify-center;
}
.avatar-image {
@apply w-full h-full object-cover;
}
.avatar-initials {
@apply text-xs font-medium text-primary-600;
}
.progress-section {
@apply pt-2 border-t border-neutral-100;
}
.progress-info {
@apply flex items-center gap-2;
}
.duration-text {
@apply text-xs text-neutral-600;
}
.chore-actions {
@apply pt-2;
}
.completion-info {
@apply pt-3 border-t border-success-200;
}
.completion-badge {
@apply flex items-center gap-2 text-xs;
}
.completion-text {
@apply text-success-700 font-medium;
}
.completion-date {
@apply text-success-600;
}
/* Mobile optimizations */
@media (max-width: 640px) {
.chore-card {
@apply p-3 space-y-3;
}
.chore-title {
@apply text-sm;
}
.chore-description {
@apply text-xs;
}
.assigned-avatar {
@apply w-6 h-6;
}
.avatar-initials {
@apply text-xs;
}
}
/* Animation states */
.chore-card:hover {
@apply scale-[1.02] transform-gpu;
}
.chore-card:active {
@apply scale-[0.98] transform-gpu;
}
/* Reduced motion */
@media (prefers-reduced-motion: reduce) {
.chore-card {
@apply transition-none;
}
.chore-card:hover,
.chore-card:active {
@apply transform-none;
}
}
</style>

View File

@ -1,61 +1,301 @@
<template> <template>
<Dialog v-model="isOpen"> <Dialog v-model="isOpen" class="chore-detail-sheet">
<div class="flex items-start justify-between mb-4"> <div class="sheet-container">
<h3 class="text-lg font-medium leading-6 text-neutral-900 dark:text-neutral-100"> <!-- Enhanced Header -->
{{ chore?.name || 'Chore details' }} <div class="sheet-header">
</h3> <div class="header-content">
<button @click="close" class="text-neutral-400 hover:text-neutral-600 dark:hover:text-neutral-300"> <div class="header-main">
<div class="chore-icon">
<BaseIcon :name="getChoreIcon(chore)" class="icon" />
</div>
<div class="header-text">
<Heading :level="2" class="chore-title">
{{ chore?.name || 'Chore Details' }}
</Heading>
<div class="chore-meta">
<span class="chore-type">{{ chore?.type === 'group' ? 'Group Task' : 'Personal Task'
}}</span>
<span class="chore-status" :class="getStatusClass(chore)">
{{ getStatusText(chore) }}
</span>
</div>
</div>
</div>
<div class="header-actions">
<Button variant="ghost" size="sm" @click="toggleFavorite" v-if="chore">
<BaseIcon :name="isFavorite ? 'heroicons:heart-solid' : 'heroicons:heart-20-solid'"
:class="{ 'text-error-500': isFavorite }" />
</Button>
<Button variant="ghost" size="sm" @click="close" class="close-button">
<BaseIcon name="heroicons:x-mark-20-solid" class="w-5 h-5" /> <BaseIcon name="heroicons:x-mark-20-solid" class="w-5 h-5" />
</button> </Button>
</div>
</div> </div>
<div v-if="chore" class="space-y-4"> <!-- Quick Actions Bar -->
<div> <div v-if="chore" class="quick-actions">
<h4 class="text-sm font-semibold mb-2">General</h4> <Button v-if="canComplete" variant="solid" color="success" size="sm" @click="handleComplete"
<dl class="grid grid-cols-2 gap-3 text-sm"> :loading="isCompleting" class="quick-action">
<div> <template #icon-left>
<dt class="font-medium text-neutral-600 dark:text-neutral-400">Type</dt> <BaseIcon name="heroicons:check-20-solid" />
<dd>{{ chore.type === 'group' ? 'Group' : 'Personal' }}</dd> </template>
Complete
</Button>
<Button v-if="canClaim" variant="solid" color="primary" size="sm" @click="handleClaim"
:loading="isClaiming" class="quick-action">
<template #icon-left>
<BaseIcon name="heroicons:hand-raised-20-solid" />
</template>
Claim
</Button>
<Button variant="outline" color="neutral" size="sm" @click="startTimer" v-if="!isTimerRunning"
class="quick-action">
<template #icon-left>
<BaseIcon name="heroicons:play-20-solid" />
</template>
Start Timer
</Button>
<Button variant="solid" color="warning" size="sm" @click="stopTimer" v-if="isTimerRunning"
class="quick-action">
<template #icon-left>
<BaseIcon name="heroicons:pause-20-solid" />
</template>
{{ formatTimerDuration(currentTimerDuration) }}
</Button>
</div> </div>
<div>
<dt class="font-medium text-neutral-600 dark:text-neutral-400">Created by</dt>
<dd>{{ chore.creator?.name || chore.creator?.email || 'Unknown' }}</dd>
</div>
<div>
<dt class="font-medium text-neutral-600 dark:text-neutral-400">Due date</dt>
<dd>{{ formatDate(chore.next_due_date) }}</dd>
</div>
<div>
<dt class="font-medium text-neutral-600 dark:text-neutral-400">Frequency</dt>
<dd>{{ frequencyLabel }}</dd>
</div>
</dl>
</div> </div>
<div v-if="chore.description"> <!-- Content Sections with Progressive Disclosure -->
<h4 class="text-sm font-semibold mb-2">Description</h4> <div class="sheet-content">
<p class="text-sm text-neutral-700 dark:text-neutral-200 whitespace-pre-wrap">{{ chore.description }} <!-- Primary Information (Always Visible) -->
</p> <Card variant="elevated" color="neutral" padding="lg" class="info-section">
<div class="section-header">
<h3 class="section-title">Overview</h3>
<div class="section-badges">
<span v-if="chore?.frequency" class="frequency-badge">
{{ getFrequencyLabel(chore.frequency) }}
</span>
<span v-if="getDueDateBadge(chore)" class="due-date-badge" :class="getDueDateClass(chore)">
{{ getDueDateBadge(chore) }}
</span>
</div>
</div> </div>
<div v-if="chore.child_chores?.length"> <div class="overview-grid">
<h4 class="text-sm font-semibold mb-2">Sub-Tasks</h4> <div class="overview-item">
<ul class="list-disc list-inside space-y-1 text-sm"> <div class="item-icon">
<li v-for="sub in chore.child_chores" :key="sub.id"> <BaseIcon name="heroicons:calendar-20-solid" />
</div>
<div class="item-content">
<span class="item-label">Due Date</span>
<span class="item-value">{{ formatDate(chore?.next_due_date) }}</span>
</div>
</div>
<div class="overview-item">
<div class="item-icon">
<BaseIcon name="heroicons:user-20-solid" />
</div>
<div class="item-content">
<span class="item-label">Created by</span>
<span class="item-value">{{ chore?.creator?.full_name || chore?.creator?.email ||
'Unknown'
}}</span>
</div>
</div>
<div class="overview-item">
<div class="item-icon">
<BaseIcon name="heroicons:arrow-path-20-solid" />
</div>
<div class="item-content">
<span class="item-label">Frequency</span>
<span class="item-value">{{ getFrequencyDescription(chore) }}</span>
</div>
</div>
<div v-if="getCurrentAssignment(chore)" class="overview-item">
<div class="item-icon">
<BaseIcon name="heroicons:user-circle-20-solid" />
</div>
<div class="item-content">
<span class="item-label">Assigned to</span>
<span class="item-value">{{ getAssignedUserName(chore) }}</span>
</div>
</div>
</div>
</Card>
<!-- Description Section (Expandable) -->
<Card v-if="chore?.description" variant="outlined" color="neutral" padding="lg"
class="description-section">
<div class="expandable-section" @click="toggleSection('description')">
<div class="section-header-expandable">
<div class="section-header-content">
<BaseIcon name="heroicons:document-text-20-solid" class="section-icon" />
<h3 class="section-title">Description</h3>
</div>
<BaseIcon
:name="expandedSections.description ? 'heroicons:chevron-up-20-solid' : 'heroicons:chevron-down-20-solid'"
class="expand-icon" />
</div>
</div>
<TransitionExpand>
<div v-if="expandedSections.description" class="section-content">
<p class="description-text">{{ chore.description }}</p>
</div>
</TransitionExpand>
</Card>
<!-- History & Progress Section (Expandable) -->
<Card variant="outlined" color="neutral" padding="lg" class="history-section">
<div class="expandable-section" @click="toggleSection('history')">
<div class="section-header-expandable">
<div class="section-header-content">
<BaseIcon name="heroicons:clock-20-solid" class="section-icon" />
<h3 class="section-title">History & Progress</h3>
<span class="completion-count">{{ getCompletionCount(chore) }} completions</span>
</div>
<BaseIcon
:name="expandedSections.history ? 'heroicons:chevron-up-20-solid' : 'heroicons:chevron-down-20-solid'"
class="expand-icon" />
</div>
</div>
<TransitionExpand>
<div v-if="expandedSections.history" class="section-content">
<div v-if="chore?.assignments && chore.assignments.length > 0" class="assignments-list">
<div v-for="assignment in chore.assignments.slice(0, showAllHistory ? undefined : 3)"
:key="assignment.id" class="assignment-item">
<div class="assignment-status">
<div :class="['status-indicator', { 'completed': assignment.is_complete }]">
<BaseIcon
:name="assignment.is_complete ? 'heroicons:check-20-solid' : 'heroicons:clock-20-solid'" />
</div>
</div>
<div class="assignment-details">
<span class="assignment-user">{{ assignment.assigned_user?.full_name ||
assignment.assigned_user?.email }}</span>
<div class="assignment-dates">
<span class="due-date">Due: {{ formatDate(assignment.due_date) }}</span>
<span v-if="assignment.completed_at" class="completed-date">
Completed: {{ formatDate(assignment.completed_at) }}
</span>
</div>
</div>
</div>
<Button v-if="chore.assignments.length > 3 && !showAllHistory" variant="ghost"
color="primary" size="sm" @click="showAllHistory = true" class="show-more-button">
Show {{ chore.assignments.length - 3 }} more assignments
</Button>
</div>
<div v-else class="no-history">
<BaseIcon name="heroicons:calendar-x-mark-20-solid" class="no-history-icon" />
<p>No completion history yet</p>
</div>
</div>
</TransitionExpand>
</Card>
<!-- Sub-tasks Section (Expandable) -->
<Card v-if="chore?.child_chores?.length" variant="outlined" color="neutral" padding="lg"
class="subtasks-section">
<div class="expandable-section" @click="toggleSection('subtasks')">
<div class="section-header-expandable">
<div class="section-header-content">
<BaseIcon name="heroicons:list-bullet-20-solid" class="section-icon" />
<h3 class="section-title">Sub-tasks</h3>
<span class="subtask-count">{{ chore.child_chores.length }} tasks</span>
</div>
<BaseIcon
:name="expandedSections.subtasks ? 'heroicons:chevron-up-20-solid' : 'heroicons:chevron-down-20-solid'"
class="expand-icon" />
</div>
</div>
<TransitionExpand>
<div v-if="expandedSections.subtasks" class="section-content">
<div class="subtasks-list">
<div v-for="sub in chore.child_chores" :key="sub.id" class="subtask-item">
<div class="subtask-checkbox">
<BaseIcon name="heroicons:check-circle-20-solid" v-if="isSubtaskComplete(sub)"
class="completed-icon" />
<div v-else class="uncompleted-circle"></div>
</div>
<span class="subtask-name" :class="{ 'completed': isSubtaskComplete(sub) }">
{{ sub.name }} {{ sub.name }}
</li> </span>
</ul> </div>
</div>
</div>
</TransitionExpand>
</Card>
<!-- Scheduling Section (Expandable) -->
<Card variant="outlined" color="neutral" padding="lg" class="scheduling-section">
<div class="expandable-section" @click="toggleSection('scheduling')">
<div class="section-header-expandable">
<div class="section-header-content">
<BaseIcon name="heroicons:calendar-days-20-solid" class="section-icon" />
<h3 class="section-title">Scheduling</h3>
</div>
<BaseIcon
:name="expandedSections.scheduling ? 'heroicons:chevron-up-20-solid' : 'heroicons:chevron-down-20-solid'"
class="expand-icon" />
</div>
</div>
<TransitionExpand>
<div v-if="expandedSections.scheduling" class="section-content">
<div class="scheduling-grid">
<div class="scheduling-item">
<span class="scheduling-label">Next Due Date</span>
<span class="scheduling-value">{{ formatDate(chore?.next_due_date) }}</span>
</div>
<div v-if="chore?.last_completed_at" class="scheduling-item">
<span class="scheduling-label">Last Completed</span>
<span class="scheduling-value">{{ formatDate(chore.last_completed_at) }}</span>
</div>
<div class="scheduling-item">
<span class="scheduling-label">Created</span>
<span class="scheduling-value">{{ formatDate(chore?.created_at) }}</span>
</div>
<div class="scheduling-item">
<span class="scheduling-label">Last Updated</span>
<span class="scheduling-value">{{ formatDate(chore?.updated_at) }}</span>
</div>
</div>
</div>
</TransitionExpand>
</Card>
</div>
<!-- Footer Actions -->
<div class="sheet-footer">
<div class="footer-actions">
<Button variant="outline" color="neutral" @click="editChore" v-if="canEdit">
<template #icon-left>
<BaseIcon name="heroicons:pencil-20-solid" />
</template>
Edit
</Button>
<Button variant="outline" color="error" @click="deleteChore" v-if="canDelete">
<template #icon-left>
<BaseIcon name="heroicons:trash-20-solid" />
</template>
Delete
</Button>
<div class="spacer"></div>
<Button variant="ghost" color="neutral" @click="close">
Close
</Button>
</div>
</div> </div>
</div> </div>
</Dialog> </Dialog>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue' import { ref, computed, onMounted, onUnmounted } from 'vue'
import { format } from 'date-fns' import { format, isToday, isTomorrow, isPast, isYesterday } from 'date-fns'
import type { ChoreWithCompletion } from '@/types/chore' import type { ChoreWithCompletion } from '@/types/chore'
import BaseIcon from '@/components/BaseIcon.vue' import BaseIcon from '@/components/BaseIcon.vue'
import Dialog from '@/components/ui/Dialog.vue' import { Dialog, Card, Button, Heading } from '@/components/ui'
import TransitionExpand from '@/components/ui/TransitionExpand.vue'
interface Props { interface Props {
modelValue: boolean modelValue: boolean
@ -63,34 +303,559 @@ interface Props {
} }
const props = defineProps<Props>() const props = defineProps<Props>()
const emit = defineEmits<{ (e: 'update:modelValue', value: boolean): void }>() const emit = defineEmits<{
(e: 'update:modelValue', value: boolean): void
(e: 'complete', chore: ChoreWithCompletion): void
(e: 'claim', chore: ChoreWithCompletion): void
(e: 'edit', chore: ChoreWithCompletion): void
(e: 'delete', chore: ChoreWithCompletion): void
}>()
// Local proxy for the v-model binding to avoid mutating the prop directly // State
const expandedSections = ref({
description: false,
history: false,
subtasks: false,
scheduling: false
})
const showAllHistory = ref(false)
const isFavorite = ref(false)
const isCompleting = ref(false)
const isClaiming = ref(false)
const isTimerRunning = ref(false)
const currentTimerDuration = ref(0)
const timerInterval = ref<number | null>(null)
// Computed
const isOpen = computed({ const isOpen = computed({
get: () => props.modelValue, get: () => props.modelValue,
set: (val: boolean) => emit('update:modelValue', val), set: (val: boolean) => emit('update:modelValue', val),
}) })
const canComplete = computed(() => {
if (!props.chore) return false
const assignment = getCurrentAssignment(props.chore)
return assignment && !assignment.is_complete
})
const canClaim = computed(() => {
if (!props.chore) return false
return props.chore.type === 'group' && !getCurrentAssignment(props.chore)
})
const canEdit = computed(() => {
// Add logic based on user permissions
return true
})
const canDelete = computed(() => {
// Add logic based on user permissions
return true
})
// Methods
function close() { function close() {
emit('update:modelValue', false) emit('update:modelValue', false)
} }
function formatDate(dateStr: string) { function formatDate(dateStr?: string) {
return format(new Date(dateStr), 'PPP') if (!dateStr) return 'Not set'
const date = new Date(dateStr)
if (isToday(date)) return 'Today'
if (isTomorrow(date)) return 'Tomorrow'
if (isYesterday(date)) return 'Yesterday'
return format(date, 'MMM d, yyyy')
} }
const frequencyLabel = computed(() => { function getChoreIcon(chore?: ChoreWithCompletion | null) {
if (!props.chore) return '' if (!chore) return 'heroicons:clipboard-document-list-20-solid'
const { frequency, custom_interval_days } = props.chore
if (frequency === 'custom' && custom_interval_days) { if (chore.type === 'group') return 'heroicons:user-group-20-solid'
return `Every ${custom_interval_days} days` return 'heroicons:user-20-solid'
} }
function getStatusClass(chore?: ChoreWithCompletion | null) {
if (!chore) return ''
const assignment = getCurrentAssignment(chore)
if (assignment?.is_complete) return 'status-completed'
if (chore.next_due_date && isPast(new Date(chore.next_due_date))) {
return 'status-overdue'
}
return 'status-pending'
}
function getStatusText(chore?: ChoreWithCompletion | null) {
if (!chore) return 'Unknown'
const assignment = getCurrentAssignment(chore)
if (assignment?.is_complete) return 'Completed'
if (chore.next_due_date && isPast(new Date(chore.next_due_date))) {
return 'Overdue'
}
return 'Pending'
}
function getCurrentAssignment(chore?: ChoreWithCompletion | null) {
if (!chore?.assignments || chore.assignments.length === 0) return null
return chore.assignments[chore.assignments.length - 1]
}
function getAssignedUserName(chore?: ChoreWithCompletion | null) {
const assignment = getCurrentAssignment(chore)
if (!assignment?.assigned_user) return 'Unassigned'
return assignment.assigned_user.full_name || assignment.assigned_user.email
}
function getFrequencyLabel(frequency?: string) {
const map: Record<string, string> = { const map: Record<string, string> = {
one_time: 'One-time', one_time: 'One-time',
daily: 'Daily', daily: 'Daily',
weekly: 'Weekly', weekly: 'Weekly',
monthly: 'Monthly', monthly: 'Monthly',
custom: 'Custom'
}
return map[frequency || ''] || frequency || 'Unknown'
}
function getFrequencyDescription(chore?: ChoreWithCompletion | null) {
if (!chore) return 'Unknown'
const { frequency, custom_interval_days } = chore
if (frequency === 'custom' && custom_interval_days) {
return `Every ${custom_interval_days} days`
}
return getFrequencyLabel(frequency)
}
function getDueDateBadge(chore?: ChoreWithCompletion | null) {
if (!chore?.next_due_date) return null
const date = new Date(chore.next_due_date)
if (isToday(date)) return 'Due Today'
if (isTomorrow(date)) return 'Due Tomorrow'
if (isPast(date)) return 'Overdue'
return null
}
function getDueDateClass(chore?: ChoreWithCompletion | null) {
if (!chore?.next_due_date) return ''
const date = new Date(chore.next_due_date)
if (isPast(date)) return 'overdue'
if (isToday(date)) return 'due-today'
if (isTomorrow(date)) return 'due-tomorrow'
return ''
}
function getCompletionCount(chore?: ChoreWithCompletion | null) {
if (!chore?.assignments) return 0
return chore.assignments.filter(a => a.is_complete).length
}
function isSubtaskComplete(subtask: any) {
// This would need to be implemented based on your subtask completion logic
return false
}
function toggleSection(section: keyof typeof expandedSections.value) {
expandedSections.value[section] = !expandedSections.value[section]
}
function toggleFavorite() {
isFavorite.value = !isFavorite.value
// Implement favorite logic
}
async function handleComplete() {
if (!props.chore) return
isCompleting.value = true
try {
emit('complete', props.chore)
} finally {
isCompleting.value = false
}
}
async function handleClaim() {
if (!props.chore) return
isClaiming.value = true
try {
emit('claim', props.chore)
} finally {
isClaiming.value = false
}
}
function editChore() {
if (props.chore) {
emit('edit', props.chore)
}
}
function deleteChore() {
if (props.chore) {
emit('delete', props.chore)
}
}
function startTimer() {
isTimerRunning.value = true
currentTimerDuration.value = 0
timerInterval.value = window.setInterval(() => {
currentTimerDuration.value += 1
}, 1000)
}
function stopTimer() {
isTimerRunning.value = false
if (timerInterval.value) {
clearInterval(timerInterval.value)
timerInterval.value = null
}
}
function formatTimerDuration(seconds: number) {
const mins = Math.floor(seconds / 60)
const secs = seconds % 60
return `${mins}:${secs.toString().padStart(2, '0')}`
}
// Cleanup timer on unmount
onUnmounted(() => {
if (timerInterval.value) {
clearInterval(timerInterval.value)
} }
return map[frequency] || frequency
}) })
</script> </script>
<style scoped>
.chore-detail-sheet {
@apply max-w-2xl mx-auto;
}
.sheet-container {
@apply flex flex-col h-full max-h-[90vh];
}
/* Header Styles */
.sheet-header {
@apply space-y-4 p-6 pb-4;
@apply border-b border-border-primary;
}
.header-content {
@apply flex items-start justify-between;
}
.header-main {
@apply flex items-start gap-4 flex-1;
}
.chore-icon {
@apply flex-shrink-0 w-12 h-12;
@apply bg-primary-100 dark:bg-primary-900;
@apply rounded-lg flex items-center justify-center;
}
.chore-icon .icon {
@apply w-6 h-6 text-primary-600 dark:text-primary-400;
}
.header-text {
@apply space-y-2;
}
.chore-title {
@apply text-xl font-semibold text-text-primary;
@apply leading-tight;
}
.chore-meta {
@apply flex items-center gap-3;
}
.chore-type {
@apply text-sm text-text-secondary;
}
.chore-status {
@apply text-xs font-medium px-2 py-1 rounded-md;
}
.chore-status.status-completed {
@apply bg-success-100 text-success-700 dark:bg-success-900/50 dark:text-success-300;
}
.chore-status.status-overdue {
@apply bg-error-100 text-error-700 dark:bg-error-900/50 dark:text-error-300;
}
.chore-status.status-pending {
@apply bg-warning-100 text-warning-700 dark:bg-warning-900/50 dark:text-warning-300;
}
.header-actions {
@apply flex items-center gap-2;
}
.quick-actions {
@apply flex items-center gap-2 flex-wrap;
}
/* Content Styles */
.sheet-content {
@apply flex-1 overflow-y-auto p-6 space-y-4;
}
.info-section {
@apply space-y-4;
}
.section-header {
@apply flex items-center justify-between;
}
.section-title {
@apply text-lg font-semibold text-text-primary;
}
.section-badges {
@apply flex items-center gap-2;
}
.frequency-badge {
@apply text-xs font-medium px-2 py-1 rounded-md;
@apply bg-neutral-100 text-neutral-700 dark:bg-neutral-800 dark:text-neutral-300;
}
.due-date-badge {
@apply text-xs font-medium px-2 py-1 rounded-md;
}
.due-date-badge.overdue {
@apply bg-error-100 text-error-700 dark:bg-error-900/50 dark:text-error-300;
}
.due-date-badge.due-today {
@apply bg-warning-100 text-warning-700 dark:bg-warning-900/50 dark:text-warning-300;
}
.due-date-badge.due-tomorrow {
@apply bg-primary-100 text-primary-700 dark:bg-primary-900/50 dark:text-primary-300;
}
.overview-grid {
@apply grid grid-cols-1 md:grid-cols-2 gap-4;
}
.overview-item {
@apply flex items-center gap-3;
}
.item-icon {
@apply flex-shrink-0 w-8 h-8;
@apply bg-surface-elevated rounded-lg;
@apply flex items-center justify-center;
}
.item-icon svg {
@apply w-4 h-4 text-text-secondary;
}
.item-content {
@apply space-y-1;
}
.item-label {
@apply text-sm text-text-secondary;
}
.item-value {
@apply text-sm font-medium text-text-primary;
}
/* Expandable Sections */
.expandable-section {
@apply cursor-pointer;
}
.section-header-expandable {
@apply flex items-center justify-between py-2;
@apply transition-colors duration-micro hover:bg-surface-hover rounded-md;
}
.section-header-content {
@apply flex items-center gap-3;
}
.section-icon {
@apply w-5 h-5 text-text-secondary;
}
.expand-icon {
@apply w-5 h-5 text-text-secondary;
@apply transition-transform duration-micro;
}
.section-content {
@apply pt-4;
}
.completion-count,
.subtask-count {
@apply text-sm text-text-secondary;
}
/* Description Section */
.description-text {
@apply text-sm text-text-primary leading-relaxed;
@apply whitespace-pre-wrap;
}
/* History Section */
.assignments-list {
@apply space-y-3;
}
.assignment-item {
@apply flex items-start gap-3;
}
.assignment-status {
@apply flex-shrink-0;
}
.status-indicator {
@apply w-8 h-8 rounded-full flex items-center justify-center;
}
.status-indicator.completed {
@apply bg-success-100 text-success-600 dark:bg-success-900/50 dark:text-success-400;
}
.status-indicator:not(.completed) {
@apply bg-neutral-100 text-neutral-500 dark:bg-neutral-800 dark:text-neutral-400;
}
.assignment-details {
@apply space-y-1;
}
.assignment-user {
@apply text-sm font-medium text-text-primary;
}
.assignment-dates {
@apply space-y-1;
}
.due-date,
.completed-date {
@apply text-xs text-text-secondary;
@apply block;
}
.show-more-button {
@apply mt-3;
}
.no-history {
@apply text-center py-8 space-y-3;
}
.no-history-icon {
@apply w-12 h-12 text-text-secondary mx-auto;
}
/* Subtasks Section */
.subtasks-list {
@apply space-y-3;
}
.subtask-item {
@apply flex items-center gap-3;
}
.subtask-checkbox {
@apply flex-shrink-0;
}
.completed-icon {
@apply w-5 h-5 text-success-600 dark:text-success-400;
}
.uncompleted-circle {
@apply w-5 h-5 rounded-full border-2 border-neutral-300 dark:border-neutral-600;
}
.subtask-name {
@apply text-sm text-text-primary;
}
.subtask-name.completed {
@apply line-through text-text-secondary;
}
/* Scheduling Section */
.scheduling-grid {
@apply grid grid-cols-1 md:grid-cols-2 gap-4;
}
.scheduling-item {
@apply space-y-1;
}
.scheduling-label {
@apply text-sm text-text-secondary;
@apply block;
}
.scheduling-value {
@apply text-sm font-medium text-text-primary;
@apply block;
}
/* Footer */
.sheet-footer {
@apply p-6 pt-4;
@apply border-t border-border-primary;
}
.footer-actions {
@apply flex items-center gap-3;
}
.spacer {
@apply flex-1;
}
/* Responsive */
@media (max-width: 640px) {
.header-main {
@apply flex-col gap-3;
}
.overview-grid {
@apply grid-cols-1;
}
.scheduling-grid {
@apply grid-cols-1;
}
.quick-actions {
@apply justify-center;
}
}
</style>

View File

@ -1,84 +1,90 @@
<template> <template>
<li :class="[ <Card as="li" :variant="isOverdue ? 'soft' : 'outlined'" :color="isOverdue ? 'error' : 'neutral'" padding="md"
'relative flex items-start gap-3 py-3 border-b border-neutral-200 dark:border-neutral-700 transition', :class="[
getDueDateStatus(chore) === 'overdue' && 'bg-warning/10', 'chore-item',
getDueDateStatus(chore) === 'due-today' && 'bg-success/10', { 'is-completed': chore.is_completed, 'is-updating': chore.updating }
]"> ]">
<!-- Checkbox + main content --> <div class="chore-content">
<label class="flex gap-3 w-full cursor-pointer select-none">
<!-- Checkbox --> <!-- Checkbox -->
<input type="checkbox" :checked="chore.is_completed" @change="emit('toggle-completion', chore)" <div class="chore-checkbox">
class="h-5 w-5 text-primary rounded-md border-neutral-300 dark:border-neutral-600 focus:ring-primary-500 focus:ring-2" /> <input type="checkbox" :checked="chore.is_completed" @change.stop="emit('toggle-completion', chore)"
class="custom-checkbox" />
<div class="flex flex-col gap-0.5 flex-1">
<!-- Title + badges -->
<div class="flex items-center gap-2">
<span :class="[chore.is_completed && !chore.updating ? 'line-through text-neutral-400' : '']">
{{ chore.name }}
</span>
<template v-if="chore.type === 'group'">
<span
class="inline-flex items-center px-1.5 py-0.5 rounded bg-primary/10 text-primary text-xs font-medium">
Group
</span>
</template>
<template v-if="getDueDateStatus(chore) === 'overdue'">
<span
class="inline-flex items-center px-1.5 py-0.5 rounded bg-danger/10 text-danger text-xs font-medium">
Overdue
</span>
</template>
<template v-else-if="getDueDateStatus(chore) === 'due-today'">
<span
class="inline-flex items-center px-1.5 py-0.5 rounded bg-warning/10 text-warning text-xs font-medium">
Due Today
</span>
</template>
<template v-else-if="getDueDateStatus(chore) === 'upcoming'">
<span
class="inline-flex items-center px-1.5 py-0.5 rounded bg-neutral/10 text-neutral text-xs font-medium">
{{ dueInText }}
</span>
</template>
</div> </div>
<!-- Description --> <!-- Main Info -->
<p v-if="chore.description" class="text-sm text-neutral-600 dark:text-neutral-300 line-clamp-2"> <div class="chore-details" @click="emit('open-details', chore)">
<div class="details-header">
<span class="chore-name">{{ chore.name }}</span>
<div class="chore-badges">
<span v-if="chore.type === 'group'" class="badge type-badge">Group</span>
<span v-if="isOverdue" class="badge overdue-badge">Overdue</span>
<span v-else-if="isDueToday" class="badge due-today-badge">Due Today</span>
</div>
</div>
<p v-if="chore.description" class="chore-description">
{{ chore.description }} {{ chore.description }}
</p> </p>
<div class="details-footer">
<!-- Subtext / time --> <span v-if="dueInText" class="due-date-text">
<span v-if="chore.subtext" class="text-xs text-neutral-500">{{ chore.subtext }}</span> <BaseIcon name="heroicons:calendar-20-solid" />
<span v-if="totalTime > 0" class="text-xs text-neutral-500"> <span>{{ dueInText }}</span>
Total Time: {{ formatDuration(totalTime) }} </span>
<span v-if="totalTime > 0" class="time-tracked-text">
<BaseIcon name="heroicons:clock-20-solid" />
<span>{{ formatDuration(totalTime) }}</span>
</span> </span>
</div> </div>
</label> </div>
<!-- Action buttons --> <!-- Action Menu -->
<div class="flex items-center gap-1 ml-auto"> <div class="chore-actions">
<Button variant="ghost" size="sm" color="neutral" @click="toggleTimer" <Menu as="div" class="relative">
:disabled="chore.is_completed || !chore.current_assignment_id"> <MenuButton as="template">
<BaseIcon :name="isActiveTimer ? 'heroicons:pause-20-solid' : 'heroicons:play-20-solid'" <Button variant="ghost" size="sm" class="action-button">
class="w-4 h-4" /> <BaseIcon name="heroicons:ellipsis-vertical-20-solid" />
</Button>
<Button variant="ghost" size="sm" color="neutral" @click="emit('open-details', chore)">
<BaseIcon name="heroicons:clipboard-document-list-20-solid" class="w-4 h-4" />
</Button>
<Button variant="ghost" size="sm" color="neutral" @click="emit('open-history', chore)">
<BaseIcon name="heroicons:calendar-days-20-solid" class="w-4 h-4" />
</Button>
<Button variant="ghost" size="sm" color="neutral" @click="emit('edit', chore)">
<BaseIcon name="heroicons:pencil-square-20-solid" class="w-4 h-4" />
</Button>
<Button variant="ghost" size="sm" color="danger" @click="emit('delete', chore)">
<BaseIcon name="heroicons:trash-20-solid" class="w-4 h-4" />
</Button> </Button>
</MenuButton>
<transition enter-active-class="transition duration-100 ease-out"
enter-from-class="transform scale-95 opacity-0" enter-to-class="transform scale-100 opacity-100"
leave-active-class="transition duration-75 ease-in"
leave-from-class="transform scale-100 opacity-100"
leave-to-class="transform scale-95 opacity-0">
<MenuItems class="action-menu">
<MenuItem v-if="!chore.is_completed" v-slot="{ active }">
<button :class="['menu-item', { 'active': active }]" @click.stop="toggleTimer">
<BaseIcon
:name="isActiveTimer ? 'heroicons:pause-circle-20-solid' : 'heroicons:play-circle-20-solid'" />
<span>{{ isActiveTimer ? 'Stop Timer' : 'Start Timer' }}</span>
</button>
</MenuItem>
<MenuItem v-slot="{ active }">
<button :class="['menu-item', { 'active': active }]"
@click.stop="emit('open-history', chore)">
<BaseIcon name="heroicons:clock-20-solid" />
<span>View History</span>
</button>
</MenuItem>
<MenuItem v-slot="{ active }">
<button :class="['menu-item', { 'active': active }]" @click.stop="emit('edit', chore)">
<BaseIcon name="heroicons:pencil-20-solid" />
<span>Edit</span>
</button>
</MenuItem>
<MenuItem v-slot="{ active }">
<button :class="['menu-item', 'danger', { 'active': active }]"
@click.stop="emit('delete', chore)">
<BaseIcon name="heroicons:trash-20-solid" />
<span>Delete</span>
</button>
</MenuItem>
</MenuItems>
</transition>
</Menu>
</div>
</div> </div>
<!-- Recursive children --> <!-- Recursive children -->
<ul v-if="chore.child_chores?.length" class="ml-5"> <ul v-if="chore.child_chores?.length" class="child-chores-list">
<ChoreItem v-for="child in chore.child_chores" :key="child.id" :chore="child" :time-entries="timeEntries" <ChoreItem v-for="child in chore.child_chores" :key="child.id" :chore="child" :time-entries="timeEntries"
:active-timer="activeTimer" @toggle-completion="emit('toggle-completion', $event)" :active-timer="activeTimer" @toggle-completion="emit('toggle-completion', $event)"
@edit="emit('edit', $event)" @delete="emit('delete', $event)" @edit="emit('edit', $event)" @delete="emit('delete', $event)"
@ -86,17 +92,18 @@
@start-timer="emit('start-timer', $event)" @start-timer="emit('start-timer', $event)"
@stop-timer="(chore, timeEntryId) => emit('stop-timer', chore, timeEntryId)" /> @stop-timer="(chore, timeEntryId) => emit('stop-timer', chore, timeEntryId)" />
</ul> </ul>
</li> </Card>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { defineProps, defineEmits, computed } from 'vue'; import { defineProps, defineEmits, computed } from 'vue';
import { formatDistanceToNow, isToday } from 'date-fns'; import { formatDistanceToNow, isToday, isPast } from 'date-fns';
import type { ChoreWithCompletion } from '../types/chore'; import { Menu, MenuButton, MenuItems, MenuItem } from '@headlessui/vue'
import type { ChoreWithCompletion } from '@/types/chore';
import type { TimeEntry } from '@/types/time_entry'; import type { TimeEntry } from '@/types/time_entry';
import { formatDuration } from '../utils/formatters'; import { formatDuration } from '@/utils/formatters';
import BaseIcon from './BaseIcon.vue'; import BaseIcon from './BaseIcon.vue';
import { Button } from '@/components/ui'; import { Button, Card } from '@/components/ui';
// --- props & emits --- // --- props & emits ---
const props = defineProps<{ const props = defineProps<{
@ -131,6 +138,9 @@ const dueInText = computed(() => {
return formatDistanceToNow(dueDate, { addSuffix: true }); return formatDistanceToNow(dueDate, { addSuffix: true });
}); });
const isOverdue = computed(() => getDueDateStatus(props.chore) === 'overdue');
const isDueToday = computed(() => getDueDateStatus(props.chore) === 'due-today');
// --- methods --- // --- methods ---
function toggleTimer() { function toggleTimer() {
if (isActiveTimer.value) { if (isActiveTimer.value) {
@ -143,14 +153,16 @@ function toggleTimer() {
function getDueDateStatus(chore: ChoreWithCompletion) { function getDueDateStatus(chore: ChoreWithCompletion) {
if (chore.is_completed) return 'completed'; if (chore.is_completed) return 'completed';
if (!chore.next_due_date) return 'none';
const today = new Date(); const today = new Date();
today.setHours(0, 0, 0, 0); today.setHours(0, 0, 0, 0);
const dueDate = new Date(chore.next_due_date); const dueDate = new Date(chore.next_due_date);
dueDate.setHours(0, 0, 0, 0); dueDate.setHours(0, 0, 0, 0);
if (dueDate < today) return 'overdue'; if (isPast(dueDate) && !isToday(dueDate)) return 'overdue';
if (dueDate.getTime() === today.getTime()) return 'due-today'; if (isToday(dueDate)) return 'due-today';
return 'upcoming'; return 'upcoming';
} }
</script> </script>
@ -160,3 +172,116 @@ export default {
name: 'ChoreItem' name: 'ChoreItem'
} }
</script> </script>
<style scoped>
.chore-item {
@apply transition-all duration-micro ease-micro;
}
.chore-item.is-updating {
@apply opacity-50;
}
.chore-content {
@apply flex items-start gap-4;
}
/* Checkbox */
.chore-checkbox {
@apply pt-0.5;
}
.custom-checkbox {
@apply h-5 w-5 rounded;
@apply text-primary-600 bg-surface-primary border-border-secondary;
@apply focus:ring-primary-500 focus:ring-offset-surface-primary;
@apply transition-colors duration-micro;
}
.is-completed .custom-checkbox {
@apply border-transparent bg-neutral-300 dark:bg-neutral-600;
}
/* Details */
.chore-details {
@apply flex-1 cursor-pointer;
}
.details-header {
@apply flex items-center gap-2 flex-wrap;
}
.chore-name {
@apply font-semibold text-text-primary;
@apply transition-colors duration-micro;
}
.is-completed .chore-name {
@apply line-through text-text-tertiary;
}
.chore-badges {
@apply flex items-center gap-2;
}
.badge {
@apply text-xs font-medium px-2 py-0.5 rounded-full;
}
.type-badge {
@apply bg-primary-100 text-primary-700 dark:bg-primary-900/50 dark:text-primary-300;
}
.overdue-badge {
@apply bg-error-100 text-error-700 dark:bg-error-900/50 dark:text-error-300;
}
.due-today-badge {
@apply bg-warning-100 text-warning-700 dark:bg-warning-900/50 dark:text-warning-300;
}
.chore-description {
@apply text-sm text-text-secondary mt-1 line-clamp-2;
}
.details-footer {
@apply flex items-center gap-4 text-xs text-text-secondary mt-2;
}
.due-date-text,
.time-tracked-text {
@apply flex items-center gap-1;
}
/* Actions */
.chore-actions {
@apply relative z-10;
}
.action-menu {
@apply absolute right-0 mt-2 w-48 origin-top-right;
@apply bg-surface-elevated rounded-md shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none;
}
.menu-item {
@apply flex items-center w-full px-3 py-2 text-sm gap-3;
@apply text-text-primary;
}
.menu-item.active {
@apply bg-surface-hover;
}
.menu-item.danger {
@apply text-error-600 dark:text-error-400;
}
.menu-item.danger.active {
@apply bg-error-50 dark:bg-error-900/50;
}
/* Children */
.child-chores-list {
@apply mt-4 pl-6 border-l-2 border-border-secondary;
}
</style>

View File

@ -0,0 +1,560 @@
<template>
<div class="chores-list-container">
<!-- Header -->
<div class="chores-header">
<Heading size="xl" class="text-neutral-900">Chores</Heading>
<div class="header-actions">
<Button variant="soft" color="primary" size="sm" @click="handleAddChore">
<template #icon-left>
<span class="material-icons text-lg">add_task</span>
</template>
Add Chore
</Button>
</div>
</div>
<!-- Chore Categories Tabs -->
<TabGroup @change="handleTabChange" v-slot="{ selectedIndex }">
<TabList class="chore-tabs">
<Tab v-for="(category, index) in choreCategories" :key="category.id" v-slot="{ selected }"
class="chore-tab" :class="getTabClasses(category, selected)">
<div class="tab-content">
<span class="tab-icon" :class="getTabIconClasses(category)">
<span class="material-icons">{{ category.icon }}</span>
</span>
<div class="tab-info">
<span class="tab-title">{{ category.title }}</span>
<span v-if="category.count > 0" class="tab-count" :class="getTabCountClasses(category)">
{{ category.count }}
</span>
</div>
</div>
</Tab>
</TabList>
<TabPanels class="chore-panels">
<TabPanel v-for="category in choreCategories" :key="category.id" class="chore-panel">
<!-- Loading State -->
<div v-if="isLoading" class="loading-state">
<div v-for="n in 3" :key="n" class="chore-skeleton">
<div class="skeleton-icon animate-skeleton"></div>
<div class="skeleton-content">
<div class="skeleton-title animate-skeleton"></div>
<div class="skeleton-subtitle animate-skeleton"></div>
</div>
<div class="skeleton-action animate-skeleton"></div>
</div>
</div>
<!-- Empty State -->
<div v-else-if="getFilteredChores(category.id).length === 0" class="empty-state">
<div class="empty-icon">
<span class="material-icons">{{ getEmptyStateIcon(category.id) }}</span>
</div>
<h3 class="empty-title">{{ getEmptyStateTitle(category.id) }}</h3>
<p class="empty-description">{{ getEmptyStateDescription(category.id) }}</p>
<Button v-if="category.id !== 'archive'" variant="soft" color="primary" size="sm"
@click="handleAddChore">
{{ getEmptyStateAction(category.id) }}
</Button>
</div>
<!-- Chores List -->
<div v-else class="chores-grid">
<TransitionGroup name="chore-list" tag="div" class="chores-container">
<ChoreCard v-for="chore in getFilteredChores(category.id)" :key="chore.id" :chore="chore"
:category="category.id" @complete="handleCompleteChore" @claim="handleClaimChore"
@view-details="handleViewDetails" class="chore-card-item" />
</TransitionGroup>
</div>
</TabPanel>
</TabPanels>
</TabGroup>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { TabGroup, TabList, Tab, TabPanels, TabPanel } from '@headlessui/vue'
import { useChoreStore } from '@/stores/choreStore'
import { useAuthStore } from '@/stores/auth'
import { useNotificationStore } from '@/stores/notifications'
import Heading from '@/components/ui/Heading.vue'
import Button from '@/components/ui/Button.vue'
import ChoreCard from '@/components/ChoreCard.vue'
interface ChoreCategory {
id: 'priority' | 'upcoming' | 'available' | 'archive'
title: string
icon: string
count: number
priority: number
}
interface Chore {
id: string
name: string
description?: string
dueDate: Date
priority: 'urgent' | 'high' | 'medium' | 'low'
assignedTo?: string
points: number
isComplete: boolean
completedAt?: Date
completedBy?: string
isOverdue: boolean
isAvailable: boolean
estimatedDuration?: number
}
const choreStore = useChoreStore()
const authStore = useAuthStore()
const notificationStore = useNotificationStore()
const isLoading = ref(true)
const selectedCategory = ref<string>('priority')
// Computed chore categories with dynamic counts
const choreCategories = computed<ChoreCategory[]>(() => {
const allChores = choreStore.allChores || []
const userId = authStore.user?.id
return [
{
id: 'priority',
title: 'Priority',
icon: 'priority_high',
count: getPriorityChores().length,
priority: 1
},
{
id: 'upcoming',
title: 'Upcoming',
icon: 'schedule',
count: getUpcomingChores().length,
priority: 2
},
{
id: 'available',
title: 'Available',
icon: 'volunteer_activism',
count: getAvailableChores().length,
priority: 3
},
{
id: 'archive',
title: 'Archive',
icon: 'archive',
count: getArchivedChores().length,
priority: 4
}
]
})
// Filter functions for each category
const getPriorityChores = (): Chore[] => {
const now = new Date()
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate())
return choreStore.allChores
.filter(chore => !chore.isComplete)
.filter(chore => {
const dueDate = new Date(chore.dueDate)
return dueDate <= today || chore.isOverdue
})
.sort((a, b) => {
// Sort by: overdue first, then by due date
if (a.isOverdue && !b.isOverdue) return -1
if (!a.isOverdue && b.isOverdue) return 1
return new Date(a.dueDate).getTime() - new Date(b.dueDate).getTime()
})
}
const getUpcomingChores = (): Chore[] => {
const now = new Date()
const weekFromNow = new Date(now.getTime() + 7 * 24 * 60 * 60 * 1000)
return choreStore.allChores
.filter(chore => !chore.isComplete && !chore.isOverdue)
.filter(chore => {
const dueDate = new Date(chore.dueDate)
return dueDate > now && dueDate <= weekFromNow
})
.sort((a, b) => new Date(a.dueDate).getTime() - new Date(b.dueDate).getTime())
}
const getAvailableChores = (): Chore[] => {
return choreStore.allChores
.filter(chore => !chore.isComplete && chore.isAvailable)
.sort((a, b) => b.points - a.points) // Sort by points descending
}
const getArchivedChores = (): Chore[] => {
const sevenDaysAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000)
return choreStore.allChores
.filter(chore => chore.isComplete && chore.completedAt && new Date(chore.completedAt) >= sevenDaysAgo)
.sort((a, b) => new Date(b.completedAt!).getTime() - new Date(a.completedAt!).getTime())
}
const getFilteredChores = (categoryId: string): Chore[] => {
switch (categoryId) {
case 'priority':
return getPriorityChores()
case 'upcoming':
return getUpcomingChores()
case 'available':
return getAvailableChores()
case 'archive':
return getArchivedChores()
default:
return []
}
}
// Tab styling functions
const getTabClasses = (category: ChoreCategory, selected: boolean) => {
const baseClasses = 'focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500/50'
if (selected) {
if (category.id === 'priority' && category.count > 0) {
return `${baseClasses} tab-selected tab-priority-active`
}
return `${baseClasses} tab-selected`
}
return `${baseClasses} tab-unselected`
}
const getTabIconClasses = (category: ChoreCategory) => {
if (category.id === 'priority' && category.count > 0) {
return 'tab-icon-priority'
}
return 'tab-icon-default'
}
const getTabCountClasses = (category: ChoreCategory) => {
if (category.id === 'priority' && category.count > 0) {
return 'tab-count-priority'
}
return 'tab-count-default'
}
// Empty state functions
const getEmptyStateIcon = (categoryId: string) => {
const icons = {
priority: 'check_circle',
upcoming: 'event_available',
available: 'volunteer_activism',
archive: 'archive'
}
return icons[categoryId as keyof typeof icons] || 'check_circle'
}
const getEmptyStateTitle = (categoryId: string) => {
const titles = {
priority: 'All caught up!',
upcoming: 'Nothing this week',
available: 'No available chores',
archive: 'No recent completions'
}
return titles[categoryId as keyof typeof titles] || 'Nothing here'
}
const getEmptyStateDescription = (categoryId: string) => {
const descriptions = {
priority: 'No urgent or overdue tasks. Great work!',
upcoming: 'No chores scheduled for this week.',
available: 'All chores are assigned or completed.',
archive: 'Complete some chores to see them here.'
}
return descriptions[categoryId as keyof typeof descriptions] || ''
}
const getEmptyStateAction = (categoryId: string) => {
const actions = {
priority: 'Add New Chore',
upcoming: 'Schedule Chore',
available: 'Create Available Chore',
archive: 'View All Chores'
}
return actions[categoryId as keyof typeof actions] || 'Add Chore'
}
// Event handlers
const handleTabChange = (index: number) => {
const category = choreCategories.value[index]
selectedCategory.value = category.id
}
const handleAddChore = () => {
// Emit event or navigate to chore creation
notificationStore.addNotification({
type: 'info',
message: 'Chore creation coming soon!',
})
}
const handleCompleteChore = async (chore: Chore) => {
try {
await choreStore.completeChore(chore.id)
// Add celebration notification
notificationStore.addNotification({
type: 'success',
message: `🎉 "${chore.name}" completed! +${chore.points} points`,
})
// Haptic feedback if available
if ('vibrate' in navigator) {
navigator.vibrate([100, 50, 100])
}
} catch (error) {
notificationStore.addNotification({
type: 'error',
message: 'Failed to complete chore. Please try again.',
})
}
}
const handleClaimChore = async (chore: Chore) => {
try {
await choreStore.claimChore(chore.id, authStore.user!.id)
notificationStore.addNotification({
type: 'success',
message: `You claimed "${chore.name}"`,
})
} catch (error) {
notificationStore.addNotification({
type: 'error',
message: 'Failed to claim chore. Please try again.',
})
}
}
const handleViewDetails = (chore: Chore) => {
// Navigate to chore details or open modal
notificationStore.addNotification({
type: 'info',
message: 'Chore details coming soon!',
})
}
// Load chores on mount
onMounted(async () => {
try {
await choreStore.fetchChores()
} catch (error) {
console.error('Failed to load chores:', error)
} finally {
isLoading.value = false
}
})
</script>
<style scoped>
.chores-list-container {
@apply space-y-6;
}
.chores-header {
@apply flex items-center justify-between;
}
.header-actions {
@apply flex items-center gap-3;
}
/* Tab Styles */
.chore-tabs {
@apply flex bg-neutral-100 rounded-xl p-1 mb-6;
@apply overflow-x-auto scrollbar-hide;
}
.chore-tab {
@apply flex-1 min-w-0 px-4 py-3 rounded-lg transition-all duration-micro;
@apply cursor-pointer;
}
.tab-selected {
@apply bg-white shadow-soft;
}
.tab-unselected {
@apply hover:bg-white/50;
}
.tab-priority-active {
@apply bg-error-50 border border-error-200;
}
.tab-content {
@apply flex items-center gap-3;
}
.tab-icon {
@apply flex-shrink-0;
}
.tab-icon-default {
@apply text-neutral-600;
}
.tab-icon-priority {
@apply text-error-500;
}
.tab-info {
@apply flex-1 min-w-0 flex items-center justify-between;
}
.tab-title {
@apply text-sm font-medium text-neutral-900 truncate;
}
.tab-count {
@apply text-xs px-2 py-1 rounded-full font-medium;
}
.tab-count-default {
@apply bg-neutral-200 text-neutral-700;
}
.tab-count-priority {
@apply bg-error-100 text-error-700;
}
/* Panel Styles */
.chore-panels {
@apply space-y-4;
}
.chore-panel {
@apply focus:outline-none;
}
/* Loading State */
.loading-state {
@apply space-y-4;
}
.chore-skeleton {
@apply flex items-center gap-4 p-4 bg-white rounded-lg border border-neutral-200;
}
.skeleton-icon {
@apply w-10 h-10 bg-neutral-200 rounded-full;
}
.skeleton-content {
@apply flex-1 space-y-2;
}
.skeleton-title {
@apply h-4 bg-neutral-200 rounded w-3/4;
}
.skeleton-subtitle {
@apply h-3 bg-neutral-200 rounded w-1/2;
}
.skeleton-action {
@apply w-20 h-8 bg-neutral-200 rounded;
}
/* Empty State */
.empty-state {
@apply text-center py-12 px-4;
}
.empty-icon {
@apply text-neutral-400 text-5xl mb-4;
}
.empty-title {
@apply text-lg font-medium text-neutral-900 mb-2;
}
.empty-description {
@apply text-sm text-neutral-600 mb-6 max-w-md mx-auto;
}
/* Chores Grid */
.chores-grid {
@apply space-y-3;
}
.chores-container {
@apply space-y-3;
}
.chore-card-item {
@apply transition-all duration-micro;
}
/* Animations */
.chore-list-enter-active,
.chore-list-leave-active {
transition: all 300ms ease-page;
}
.chore-list-enter-from {
opacity: 0;
transform: translateY(20px) scale(0.95);
}
.chore-list-leave-to {
opacity: 0;
transform: translateX(-20px) scale(0.95);
}
.chore-list-move {
transition: transform 300ms ease-page;
}
/* Mobile Optimizations */
@media (max-width: 640px) {
.chore-tabs {
@apply mb-4;
}
.tab-content {
@apply gap-2;
}
.tab-title {
@apply text-xs;
}
.tab-count {
@apply text-xs px-1.5 py-0.5;
}
.empty-state {
@apply py-8;
}
.empty-icon {
@apply text-4xl mb-3;
}
.empty-title {
@apply text-base;
}
}
/* Reduced Motion */
@media (prefers-reduced-motion: reduce) {
.chore-list-enter-active,
.chore-list-leave-active,
.chore-list-move {
transition: none;
}
.chore-card-item {
transition: none;
}
}
</style>

View File

@ -1,84 +1,252 @@
<template> <template>
<section> <div class="invite-manager">
<Heading :level="3" class="mb-4">{{ t('inviteManager.title', 'Household Invites') }}</Heading> <div v-if="invite" class="active-invite-section">
<p class="section-description">Share this code or QR with new members to let them join.</p>
<!-- Generate / regenerate button --> <div class="invite-display">
<Button :disabled="generating" @click="generateInvite" class="mb-3"> <div class="qr-code-wrapper">
<Spinner v-if="generating" class="mr-2" size="sm" /> <div v-if="qrCodeUrl" class="qr-code">
{{ inviteCode ? t('inviteManager.regenerate', 'Regenerate Invite Code') : t('inviteManager.generate', <img :src="qrCodeUrl" alt="Household Invite QR Code" />
'Generate Invite Code') }} </div>
<div v-else class="qr-code-placeholder">
<Spinner size="md" />
</div>
</div>
<div class="invite-code-wrapper">
<span class="invite-code">{{ formattedInviteCode }}</span>
</div>
</div>
<div class="invite-actions">
<Button variant="outline" size="sm" @click="copyInvite" :disabled="!clipboardSupported">
<BaseIcon :name="copied ? 'heroicons:check-20-solid' : 'heroicons:clipboard-document'" />
<span>{{ copied ? 'Copied!' : 'Copy Code' }}</span>
</Button> </Button>
<Button variant="outline" size="sm" @click="copyDeepLink">
<!-- Active invite display --> <BaseIcon name="heroicons:link" />
<div v-if="inviteCode" class="mb-2 flex items-center gap-2"> <span>Copy Deep Link</span>
<Input :model-value="inviteCode" readonly class="flex-1" /> </Button>
<Button variant="outline" color="secondary" size="sm" :disabled="!clipboardSupported" @click="copyInvite"> <Button variant="outline" size="sm" @click="shareInvite">
{{ copied ? t('inviteManager.copied', 'Copied!') : t('inviteManager.copy', 'Copy') }} <BaseIcon name="heroicons:share" />
<span>Share Link</span>
</Button> </Button>
</div> </div>
<Alert v-if="error" type="error" :message="error" />
</section> <div class="invite-footer">
<p class="expiry-info">Expires: {{ expiryDate }}</p>
<Button variant="ghost" color="error" size="sm" @click="revokeInvite" :loading="revoking">
Revoke Invite
</Button>
</div>
</div>
<div v-else class="no-invite-section">
<p class="section-description">Generate an invite to allow new members to join your household.</p>
<Button @click="generateInvite" :loading="generating" full-width>
<template #icon-left>
<BaseIcon name="heroicons:plus-circle-20-solid" />
</template>
Generate Invite Code
</Button>
</div>
<Alert v-if="error" type="error" :message="error" class="mt-4" />
</div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted, watch } from 'vue' import { ref, onMounted, watch, computed } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { useClipboard } from '@vueuse/core' import { useClipboard, useShare } from '@vueuse/core'
import QRCode from 'qrcode'
import { formatDistanceToNow, parseISO } from 'date-fns'
import { apiClient, API_ENDPOINTS } from '@/services/api' import { apiClient, API_ENDPOINTS } from '@/services/api'
import Button from '@/components/ui/Button.vue' import { Button, Spinner, Alert } from '@/components/ui'
import Heading from '@/components/ui/Heading.vue' import BaseIcon from '@/components/BaseIcon.vue'
import Input from '@/components/ui/Input.vue' import { useNotificationStore } from '@/stores/notifications'
import Spinner from '@/components/ui/Spinner.vue' import { track } from '@/utils/analytics'
import Alert from '@/components/ui/Alert.vue'
interface Props { interface Props {
groupId: number groupId: number
} }
defineProps<Props>()
const props = defineProps<Props>() const props = defineProps<Props>()
interface Invite {
code: string;
expires_at: string;
}
const { t } = useI18n() const { t } = useI18n()
const notifications = useNotificationStore()
const inviteCode = ref<string | null>(null) const invite = ref<Invite | null>(null)
const generating = ref(false) const generating = ref(false)
const revoking = ref(false)
const error = ref<string | null>(null) const error = ref<string | null>(null)
const qrCodeUrl = ref<string | null>(null)
const { copy, copied, isSupported: clipboardSupported } = useClipboard({ legacy: true }) const { copy, copied, isSupported: clipboardSupported } = useClipboard({ legacy: true, copiedDuring: 1500 })
const { share, isSupported: shareSupported } = useShare()
const deepLinkScheme = 'myapp://join/'
const inviteLink = computed(() => `${window.location.origin}/join?invite_code=${invite.value?.code}`)
const deepLink = computed(() => `${deepLinkScheme}${invite.value?.code || ''}`)
const formattedInviteCode = computed(() => {
const code = invite.value?.code || ''
return code.match(/.{1,4}/g)?.join('-') || code
})
const expiryDate = computed(() => {
if (!invite.value) return ''
return formatDistanceToNow(parseISO(invite.value.expires_at), { addSuffix: true })
})
async function generateQrCode() {
if (!invite.value) {
qrCodeUrl.value = null
return
}
try {
qrCodeUrl.value = await QRCode.toDataURL(inviteLink.value, {
errorCorrectionLevel: 'H',
margin: 2,
width: 160,
})
} catch (err) {
console.error('Failed to generate QR code', err)
qrCodeUrl.value = null
}
}
async function fetchActiveInvite() { async function fetchActiveInvite() {
if (!props.groupId) return if (!props.groupId) return
try { try {
const res = await apiClient.get(API_ENDPOINTS.GROUPS.GET_ACTIVE_INVITE(String(props.groupId))) const { data } = await apiClient.get<Invite>(API_ENDPOINTS.GROUPS.GET_ACTIVE_INVITE(String(props.groupId)))
if (res.data && res.data.code) { invite.value = data
inviteCode.value = res.data.code
}
} catch (err: any) { } catch (err: any) {
// silent absence of active invite is OK invite.value = null
} }
} }
async function generateInvite() { async function generateInvite() {
if (!props.groupId) return
generating.value = true generating.value = true
error.value = null error.value = null
try { try {
const res = await apiClient.post(API_ENDPOINTS.GROUPS.CREATE_INVITE(String(props.groupId))) const { data } = await apiClient.post<Invite>(API_ENDPOINTS.GROUPS.CREATE_INVITE(String(props.groupId)))
inviteCode.value = res.data.code invite.value = data
notifications.addNotification({ type: 'success', message: 'New invite generated.' })
track('invite_generated', { groupId: props.groupId })
} catch (err: any) { } catch (err: any) {
error.value = err?.response?.data?.detail || err.message || t('inviteManager.generateError', 'Failed to generate invite') error.value = err?.response?.data?.detail || 'Failed to generate invite'
} finally { } finally {
generating.value = false generating.value = false
} }
} }
async function copyInvite() { async function revokeInvite() {
if (!inviteCode.value) return if (!invite.value) return
await copy(inviteCode.value) revoking.value = true
try {
await apiClient.delete(API_ENDPOINTS.GROUPS.REVOKE_INVITE(String(props.groupId), invite.value.code))
notifications.addNotification({ type: 'success', message: 'Invite has been revoked.' })
invite.value = null
track('invite_revoked', { groupId: props.groupId })
} catch (err) {
notifications.addNotification({ type: 'error', message: 'Failed to revoke invite.' })
} finally {
revoking.value = false
}
}
function copyInvite() {
if (!invite.value) return
copy(invite.value.code)
notifications.addNotification({ type: 'info', message: 'Invite code copied to clipboard.' })
track('invite_code_copied', { groupId: props.groupId })
}
function copyDeepLink() {
if (!invite.value) return
copy(deepLink.value)
notifications.addNotification({ type: 'info', message: 'Deep link copied to clipboard.' })
track('invite_deeplink_copied', { groupId: props.groupId })
}
function shareInvite() {
if (!shareSupported || !invite.value) {
notifications.addNotification({ type: 'error', message: 'Web Share API not supported in your browser.' })
return
}
share({
title: 'Join my Household!',
text: `Use this code to join my household: ${invite.value.code}`,
url: inviteLink.value
})
track('invite_shared', { groupId: props.groupId })
} }
onMounted(fetchActiveInvite) onMounted(fetchActiveInvite)
watch(() => props.groupId, fetchActiveInvite)
watch(() => props.groupId, fetchActiveInvite, { immediate: true })
watch(invite, generateQrCode, { immediate: true })
</script> </script>
<style scoped></style> <style scoped>
.invite-manager {
@apply w-full;
}
.section-description {
@apply text-sm text-text-secondary mb-4;
}
.active-invite-section {
@apply space-y-4;
}
.invite-display {
@apply flex flex-col items-center gap-4 p-4 rounded-lg bg-surface-soft;
}
.qr-code-wrapper {
@apply w-48 h-48 flex items-center justify-center bg-white dark:bg-neutral-900 rounded-lg p-2 shadow-md transition-opacity duration-150 ease-out;
}
.qr-code-placeholder {
@apply w-full h-full flex items-center justify-center bg-neutral-100;
}
.invite-code-wrapper {
@apply w-full text-center p-3 bg-white dark:bg-neutral-900 rounded-md border border-border-secondary;
}
.invite-code {
@apply text-2xl font-bold tracking-widest text-primary-600 dark:text-primary-400 font-mono;
}
.invite-actions {
@apply grid grid-cols-2 gap-3;
}
.invite-actions .button {
@apply w-full;
}
.invite-footer {
@apply flex items-center justify-between text-xs text-text-secondary;
}
.no-invite-section {
@apply text-center;
}
.qr-code-enter-from {
opacity: 0;
}
.qr-code-enter-to {
opacity: 1;
}
</style>

View File

@ -0,0 +1,247 @@
<template>
<Card :variant="isPinned ? 'elevated' : 'outlined'" :color="list.archived_at ? 'neutral' : 'primary'"
:class="['list-card', `view-mode-${viewMode}`, { 'archived': list.archived_at }]" :interactive="true"
@click="$emit('open', list)">
<div class="card-content">
<!-- Card Header -->
<div class="card-header">
<div class="header-main">
<div class="header-icon" :class="`type-${list.type || 'custom'}`">
<BaseIcon :name="listIcon" />
</div>
<div class="header-text">
<h4 class="list-name">{{ list.name }}</h4>
<p class="last-activity">{{ lastActivity }}</p>
</div>
</div>
<div class="header-actions">
<Menu as="div" class="relative">
<MenuButton as="template">
<Button variant="ghost" size="sm" class="action-button">
<BaseIcon name="heroicons:ellipsis-vertical-20-solid" />
</Button>
</MenuButton>
<transition enter-active-class="transition duration-100 ease-out"
enter-from-class="transform scale-95 opacity-0"
enter-to-class="transform scale-100 opacity-100"
leave-active-class="transition duration-75 ease-in"
leave-from-class="transform scale-100 opacity-100"
leave-to-class="transform scale-95 opacity-0">
<MenuItems class="action-menu">
<MenuItem v-if="!list.archived_at" v-slot="{ active }">
<button :class="['menu-item', { 'active': active }]" @click.stop="$emit('edit', list)">
<BaseIcon name="heroicons:pencil-20-solid" />
<span>Edit</span>
</button>
</MenuItem>
<MenuItem v-slot="{ active }">
<button :class="['menu-item', { 'active': active }]"
@click.stop="$emit('archive', list)">
<BaseIcon
:name="list.archived_at ? 'heroicons:archive-box-arrow-down' : 'heroicons:archive-box-x-mark'" />
<span>{{ list.archived_at ? 'Unarchive' : 'Archive' }}</span>
</button>
</MenuItem>
<MenuItem v-slot="{ active }">
<button :class="['menu-item', 'danger', { 'active': active }]"
@click.stop="$emit('delete', list)">
<BaseIcon name="heroicons:trash-20-solid" />
<span>Delete</span>
</button>
</MenuItem>
</MenuItems>
</transition>
</Menu>
</div>
</div>
<!-- List Preview -->
<div class="list-preview" v-if="list.items && list.items.length > 0">
<ul class="preview-items">
<li v-for="item in list.items.slice(0, 3)" :key="item.id" class="preview-item">
<BaseIcon :name="item.is_complete ? 'heroicons:check-circle-20-solid' : 'heroicons:circle'"
:class="['item-icon', { 'completed': item.is_complete }]" />
<span :class="{ 'completed-text': item.is_complete }">{{ item.name }}</span>
</li>
</ul>
<div v-if="list.items.length > 3" class="more-items">
+{{ list.items.length - 3 }} more
</div>
</div>
<p v-else class="empty-preview">
No items in this list yet.
</p>
<!-- Card Footer -->
<div class="card-footer">
<div class="progress-bar" v-if="completionPercentage > 0">
<div class="progress-fill" :style="{ width: `${completionPercentage}%` }"></div>
</div>
<div class="footer-meta">
<span class="item-count">{{ list.items.length }} items</span>
<span class="completion-status">{{ completionPercentage }}% complete</span>
</div>
</div>
</div>
</Card>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { formatDistanceToNow, parseISO } from 'date-fns'
import { Menu, MenuButton, MenuItems, MenuItem } from '@headlessui/vue'
import type { List } from '@/types/list'
import { Card, Button } from '@/components/ui'
import BaseIcon from '@/components/BaseIcon.vue'
const props = defineProps<{
list: List
viewMode: 'grid' | 'list'
isPinned?: boolean
}>()
defineEmits<{
(e: 'open', list: List): void
(e: 'edit', list: List): void
(e: 'archive', list: List): void
(e: 'delete', list: List): void
}>()
const listIcon = computed(() => {
if (props.list.type === 'shopping') return 'heroicons:shopping-cart-20-solid'
if (props.list.type === 'todo') return 'heroicons:check-circle-20-solid'
return 'heroicons:list-bullet-20-solid'
})
const lastActivity = computed(() => {
const date = props.list.updated_at || props.list.created_at
if (!date) return 'No activity yet'
return `Updated ${formatDistanceToNow(parseISO(date), { addSuffix: true })}`
})
const completionPercentage = computed(() => {
if (!props.list.items || props.list.items.length === 0) return 0
const completedCount = props.list.items.filter(item => item.is_complete).length
return Math.round((completedCount / props.list.items.length) * 100)
})
</script>
<style scoped>
.list-card {
@apply transition-all duration-micro ease-micro;
}
.list-card.archived {
@apply opacity-60 hover:opacity-100;
}
.card-content {
@apply flex flex-col h-full;
}
.card-header {
@apply flex items-start justify-between;
}
.header-main {
@apply flex items-start gap-3;
}
.header-icon {
@apply w-10 h-10 rounded-lg flex items-center justify-center flex-shrink-0;
}
.header-icon.type-shopping {
@apply bg-success-100 text-success-600 dark:bg-success-900/50 dark:text-success-400;
}
.header-icon.type-todo {
@apply bg-primary-100 text-primary-600 dark:bg-primary-900/50 dark:text-primary-400;
}
.header-icon.type-custom {
@apply bg-warning-100 text-warning-600 dark:bg-warning-900/50 dark:text-warning-400;
}
.header-text {
@apply space-y-1;
}
.list-name {
@apply font-semibold text-text-primary;
}
.last-activity {
@apply text-xs text-text-secondary;
}
.action-menu {
@apply absolute right-0 mt-2 w-48 origin-top-right;
@apply bg-surface-elevated rounded-md shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none;
@apply z-10;
}
.menu-item {
@apply flex items-center w-full px-3 py-2 text-sm gap-3;
}
.menu-item.active {
@apply bg-surface-hover;
}
.menu-item.danger {
@apply text-error-600 dark:text-error-400;
}
.menu-item.danger.active {
@apply bg-error-50 dark:bg-error-900/50;
}
.list-preview {
@apply my-4 flex-1;
}
.preview-items {
@apply space-y-2;
}
.preview-item {
@apply flex items-center gap-2 text-sm text-text-secondary;
}
.item-icon {
@apply w-4 h-4;
}
.item-icon.completed {
@apply text-success-500;
}
.completed-text {
@apply line-through text-text-tertiary;
}
.more-items {
@apply text-xs text-text-tertiary mt-2;
}
.empty-preview {
@apply text-sm text-text-secondary my-4 flex-1 text-center py-4;
}
.card-footer {
@apply space-y-2 pt-4 border-t border-border-primary;
}
.progress-bar {
@apply h-1.5 bg-surface-soft rounded-full overflow-hidden;
}
.progress-fill {
@apply h-full bg-success-500 rounded-full transition-all duration-medium;
}
.footer-meta {
@apply flex justify-between text-xs text-text-secondary;
}
</style>

View File

@ -0,0 +1,599 @@
<template>
<div class="list-directory">
<!-- Header -->
<div class="directory-header">
<div class="header-content">
<Heading :level="2" class="page-title">Your Lists</Heading>
<div class="header-actions">
<Button variant="outline" color="neutral" size="sm" @click="toggleViewMode">
<template #icon-left>
<BaseIcon :name="viewMode === 'grid' ? 'heroicons:view-list' : 'heroicons:view-grid'" />
</template>
{{ viewMode === 'grid' ? 'List' : 'Grid' }}
</Button>
<Button variant="solid" color="primary" @click="showCreateModal = true">
<template #icon-left>
<BaseIcon name="heroicons:plus-20-solid" />
</template>
New List
</Button>
</div>
</div>
<!-- Filters -->
<div class="filters-section">
<TabGroup :selectedIndex="selectedFilter" @change="setSelectedFilter">
<TabList class="filter-tabs">
<Tab v-for="filter in filters" :key="filter.id" v-slot="{ selected }" as="template">
<button :class="['filter-tab', { 'selected': selected }]">
<BaseIcon :name="filter.icon" class="filter-icon" />
<span class="filter-label">{{ filter.label }}</span>
<span v-if="filter.count > 0" class="filter-count">{{ filter.count }}</span>
</button>
</Tab>
</TabList>
</TabGroup>
</div>
</div>
<!-- Loading State -->
<div v-if="isLoading" class="loading-state">
<Spinner size="lg" />
<span class="loading-text">Loading your lists...</span>
</div>
<!-- Error State -->
<Alert v-else-if="error" type="error" :title="'Failed to load lists'" :message="error" class="mb-6" />
<!-- Lists Content -->
<div v-else class="lists-content">
<!-- Pinned Shopping List -->
<div v-if="pinnedShoppingList" class="pinned-section">
<div class="section-header">
<BaseIcon name="heroicons:pin-20-solid"
class="section-icon text-primary-600 dark:text-primary-400" />
<h3 class="section-title">Pinned List</h3>
</div>
<ListCard :list="pinnedShoppingList" :view-mode="viewMode" :is-pinned="true" @open="handleOpenList"
@edit="handleEditList" @archive="handleArchiveList" @delete="handleDeleteList" />
</div>
<!-- Main Lists Section -->
<div v-if="activeLists.length > 0" class="active-section">
<div class="section-header">
<BaseIcon :name="currentFilter.icon" class="section-icon" />
<h3 class="section-title">{{ currentFilter.label }}</h3>
<span class="section-count">{{ activeLists.length }}</span>
</div>
<div :class="viewModeClasses">
<TransitionGroup name="list-item" tag="div" class="lists-container">
<ListCard v-for="list in activeLists" :key="list.id" :list="list" :view-mode="viewMode"
@open="handleOpenList" @edit="handleEditList" @archive="handleArchiveList"
@delete="handleDeleteList" class="list-card-item" />
</TransitionGroup>
</div>
</div>
<!-- Smart Suggestions -->
<div v-if="suggestedLists.length > 0 && selectedFilter !== 3" class="suggestions-section">
<div class="section-header">
<BaseIcon name="heroicons:light-bulb-20-solid" class="section-icon text-warning-500" />
<h3 class="section-title">Start a New List</h3>
</div>
<div class="suggestions-grid">
<Card v-for="suggestion in suggestedLists" :key="suggestion.id" variant="elevated" padding="md"
:interactive="true" class="suggestion-card" @click="handleCreateFromSuggestion(suggestion)">
<div class="suggestion-content">
<div class="suggestion-icon-wrapper">
<BaseIcon :name="suggestion.icon" class="suggestion-icon" />
</div>
<div class="suggestion-text">
<h4 class="suggestion-title">{{ suggestion.title }}</h4>
<p class="suggestion-description">{{ suggestion.description }}</p>
</div>
<BaseIcon name="heroicons:chevron-right-20-solid" class="suggestion-arrow" />
</div>
</Card>
</div>
</div>
<!-- Empty State -->
<div v-if="activeLists.length === 0 && !pinnedShoppingList" class="empty-state">
<div class="empty-icon-wrapper">
<BaseIcon name="heroicons:clipboard-document-list-20-solid" class="empty-icon" />
</div>
<h3 class="empty-title">{{ currentFilter.emptyTitle }}</h3>
<p class="empty-description">{{ currentFilter.emptyDescription }}</p>
<Button variant="solid" color="primary" @click="showCreateModal = true">
<template #icon-left>
<BaseIcon name="heroicons:plus-20-solid" />
</template>
Create Your First List
</Button>
</div>
</div>
<!-- Create List Modal -->
<CreateListModal v-model="showCreateModal" @created="handleListCreated" :template="creationTemplate" />
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, watch } from 'vue'
import { useRouter } from 'vue-router'
import { TabGroup, TabList, Tab } from '@headlessui/vue'
import { useNotificationStore } from '@/stores/notifications'
import { useStorage } from '@vueuse/core'
import type { List } from '@/types/list'
import { formatDistanceToNow, parseISO } from 'date-fns'
import { apiClient, API_ENDPOINTS } from '@/services/api'
// UI Components
import { Heading, Button, Spinner, Alert, Card } from '@/components/ui'
import ListCard from '@/components/ListCard.vue'
import CreateListModal from './CreateListModal.vue'
import BaseIcon from '@/components/BaseIcon.vue'
interface ListFilter {
id: string
label: string
icon: string
count: number
emptyTitle: string
emptyDescription: string
filter: (lists: List[]) => List[]
}
interface ListSuggestion {
id: string
title: string
description: string
icon: string
template: {
name: string
description: string
type: 'shopping' | 'todo' | 'custom'
items?: { name: string }[]
}
}
const router = useRouter()
const notificationStore = useNotificationStore()
// Local state for managing lists directly
const allLists = ref<List[]>([])
const isLoading = ref(true)
const error = ref<string | null>(null)
const selectedFilter = ref(0)
const viewMode = useStorage<'grid' | 'list'>('list-directory-view', 'grid')
const showCreateModal = ref(false)
const creationTemplate = ref<ListSuggestion['template'] | null>(null)
// --- API Actions ---
async function fetchLists() {
isLoading.value = true
error.value = null
try {
const response = await apiClient.get(API_ENDPOINTS.LISTS.BASE)
allLists.value = response.data
} catch (err: any) {
error.value = err.response?.data?.detail || 'Failed to load lists.'
notificationStore.addNotification({
type: 'error',
message: 'Could not fetch your lists. Please try again.'
})
} finally {
isLoading.value = false
}
}
async function archiveList(list: List) {
const isArchiving = !list.archived_at
const originalStatus = list.archived_at
list.archived_at = isArchiving ? new Date().toISOString() : null
try {
if (isArchiving) {
await apiClient.post(API_ENDPOINTS.LISTS.ARCHIVE(String(list.id)))
} else {
await apiClient.post(API_ENDPOINTS.LISTS.UNARCHIVE(String(list.id)))
}
notificationStore.addNotification({
type: 'success',
message: `"${list.name}" ${isArchiving ? 'archived' : 'unarchived'}.`
})
} catch (err) {
list.archived_at = originalStatus // Rollback on error
notificationStore.addNotification({
type: 'error',
message: `Failed to ${isArchiving ? 'archive' : 'unarchive'} list.`
})
}
}
async function deleteList(list: List) {
if (!confirm(`Are you sure you want to permanently delete "${list.name}"? This action cannot be undone.`)) {
return
}
const originalLists = [...allLists.value]
allLists.value = allLists.value.filter(l => l.id !== list.id)
try {
await apiClient.delete(API_ENDPOINTS.LISTS.BY_ID(String(list.id)))
notificationStore.addNotification({
type: 'success',
message: `"${list.name}" was permanently deleted.`
})
} catch (err) {
allLists.value = originalLists // Rollback on error
notificationStore.addNotification({
type: 'error',
message: 'Failed to delete list.'
})
}
}
// Computed properties
const pinnedShoppingList = computed(() =>
allLists.value.find((list: List) =>
list.type === 'shopping' &&
!list.archived_at
)
)
const filters = computed((): ListFilter[] => [
{
id: 'recent',
label: 'Recent',
icon: 'heroicons:clock-20-solid',
count: recentLists.value.length,
emptyTitle: 'No recent activity',
emptyDescription: 'Lists you\'ve used recently will appear here.',
filter: (lists: List[]) => recentLists.value
},
{
id: 'active',
label: 'Active',
icon: 'heroicons:fire-20-solid',
count: activeLists.value.length,
emptyTitle: 'No active lists',
emptyDescription: 'Create a list to start organizing your tasks.',
filter: (lists: List[]) => lists.filter((list: List) => !list.archived_at && hasRecentActivity(list))
},
{
id: 'all',
label: 'All Lists',
icon: 'heroicons:list-bullet-20-solid',
count: allLists.value.filter((list: List) => !list.archived_at).length,
emptyTitle: 'No lists yet',
emptyDescription: 'Create your first list to get started.',
filter: (lists: List[]) => lists.filter((list: List) => !list.archived_at && list.id !== pinnedShoppingList.value?.id)
},
{
id: 'archived',
label: 'Archived',
icon: 'heroicons:archive-box-20-solid',
count: archivedLists.value.length,
emptyTitle: 'No archived lists',
emptyDescription: 'Archived lists will appear here.',
filter: (lists: List[]) => archivedLists.value
}
])
const currentFilter = computed(() => filters.value[selectedFilter.value] || filters.value[0])
const recentLists = computed(() =>
allLists.value
.filter((list: List) => !list.archived_at && list.id !== pinnedShoppingList.value?.id)
.filter((list: List) => hasRecentActivity(list))
.sort((a: List, b: List) => new Date(b.updated_at).getTime() - new Date(a.updated_at).getTime())
.slice(0, 10)
)
const activeLists = computed(() => {
const filtered = currentFilter.value.filter(allLists.value)
return filtered.filter((list: List) => list.id !== pinnedShoppingList.value?.id)
})
const archivedLists = computed(() =>
allLists.value.filter((list: List) => list.archived_at)
)
const viewModeClasses = computed(() => ({
'lists-grid': viewMode.value === 'grid',
'lists-list': viewMode.value === 'list'
}))
// List suggestions based on user patterns
const suggestedLists = computed((): ListSuggestion[] => {
if (allLists.value.length >= 5) return [] // Don't show suggestions if user already has many lists
const suggestions: ListSuggestion[] = [
{
id: 'weekly-shopping',
title: 'Weekly Groceries',
description: 'Organize your grocery shopping with smart categorization.',
icon: 'heroicons:shopping-cart-20-solid',
template: {
name: 'Weekly Groceries',
description: 'Smart grocery list with categorization',
type: 'shopping',
items: [{ name: 'Milk' }, { name: 'Bread' }, { name: 'Eggs' }, { name: 'Fruits & Vegetables' }]
}
},
{
id: 'weekend-tasks',
title: 'Weekend Tasks',
description: 'Keep track of things to do on the weekend.',
icon: 'heroicons:calendar-days-20-solid',
template: {
name: 'Weekend Tasks',
description: 'Tasks and activities for the weekend',
type: 'todo'
}
},
{
id: 'meal-planning',
title: 'Meal Planning',
description: 'Plan your meals and ingredients for the week.',
icon: 'heroicons:cake-20-solid',
template: {
name: 'Weekly Meal Plan',
description: 'Plan meals and required ingredients',
type: 'custom',
items: [{ name: 'Monday Dinner' }, { name: 'Tuesday Lunch' }, { name: 'Wednesday Dinner' }]
}
}
]
return suggestions.filter(suggestion => {
// Don't suggest if similar list already exists
return !allLists.value.some((list: List) =>
list.name.toLowerCase().includes(suggestion.template.name.toLowerCase().split(' ')[0])
)
})
})
// Helper functions
const hasRecentActivity = (list: List): boolean => {
const threeDaysAgo = new Date()
threeDaysAgo.setDate(threeDaysAgo.getDate() - 3)
const listDate = new Date(list.updated_at)
return listDate > threeDaysAgo
}
// Actions
const toggleViewMode = () => {
viewMode.value = viewMode.value === 'grid' ? 'list' : 'grid'
}
const setSelectedFilter = (index: number) => {
selectedFilter.value = index
}
const handleOpenList = (list: List) => {
router.push(`/lists/${list.id}`)
}
const handleEditList = (list: List) => {
// This could open the CreateListModal in "edit mode"
notificationStore.addNotification({
type: 'info',
message: 'Edit functionality coming soon!'
})
}
const handleArchiveList = async (list: List) => {
await archiveList(list)
}
const handleDeleteList = async (list: List) => {
await deleteList(list)
}
const handleCreateFromSuggestion = (suggestion: ListSuggestion) => {
creationTemplate.value = suggestion.template
showCreateModal.value = true
}
const handleListCreated = (newList: List) => {
notificationStore.addNotification({
type: 'success',
message: `"${newList.name}" created successfully!`
})
// Add the new list to our local state and navigate
allLists.value.unshift(newList)
router.push(`/lists/${newList.id}`)
}
// Lifecycle
onMounted(fetchLists)
</script>
<style scoped>
.list-directory {
@apply space-y-6;
}
.directory-header {
@apply space-y-4;
}
.header-content {
@apply flex items-center justify-between;
}
.page-title {
@apply text-2xl font-bold text-neutral-900 dark:text-neutral-100;
}
.header-actions {
@apply flex items-center gap-2;
}
.filters-section {
@apply border-b border-neutral-200 dark:border-neutral-700;
}
.filter-tabs {
@apply flex items-center gap-2 overflow-x-auto pb-2;
}
.filter-tab {
@apply flex items-center gap-2 px-3 py-1.5 rounded-lg text-sm font-medium transition-all duration-micro;
@apply text-text-secondary hover:text-text-primary;
@apply hover:bg-surface-hover;
@apply whitespace-nowrap cursor-pointer;
}
.filter-tab.selected {
@apply bg-primary-100 text-primary-700 dark:bg-primary-900/50 dark:text-primary-300;
@apply hover:bg-primary-100/80;
}
.filter-icon {
@apply w-5 h-5;
}
.filter-label {
@apply font-semibold;
}
.filter-count {
@apply bg-neutral-200 dark:bg-neutral-700 text-neutral-600 dark:text-neutral-300 px-2 py-0.5 rounded-full text-xs font-semibold;
}
.filter-tab.selected .filter-count {
@apply bg-primary-200 text-primary-800 dark:bg-primary-500/50 dark:text-primary-200;
}
.loading-state {
@apply flex flex-col items-center justify-center py-12 space-y-4;
}
.loading-text {
@apply text-neutral-600 dark:text-neutral-400;
}
.lists-content {
@apply space-y-8;
}
.pinned-section,
.active-section,
.suggestions-section {
@apply space-y-4;
}
.section-header {
@apply flex items-center gap-2;
@apply mb-3;
}
.section-icon {
@apply w-5 h-5 text-text-secondary;
}
.section-title {
@apply text-lg font-semibold text-text-primary;
}
.section-count {
@apply ml-auto bg-neutral-100 dark:bg-neutral-800 text-neutral-700 dark:text-neutral-300 px-3 py-1 rounded-full text-sm font-medium;
}
.lists-grid {
@apply grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4;
}
.lists-list {
@apply space-y-3;
}
.lists-container {
@apply contents;
}
.suggestions-grid {
@apply grid grid-cols-1 md:grid-cols-2 gap-4;
}
.suggestion-card {
@apply transition-all duration-micro;
}
.suggestion-content {
@apply flex items-center gap-4;
}
.suggestion-icon-wrapper {
@apply w-10 h-10 bg-primary-100 dark:bg-primary-900/50 rounded-lg flex items-center justify-center flex-shrink-0;
}
.suggestion-icon {
@apply w-5 h-5 text-primary-600 dark:text-primary-400;
}
.suggestion-text {
@apply flex-1 min-w-0;
}
.suggestion-title {
@apply font-semibold text-text-primary;
}
.suggestion-description {
@apply text-sm text-text-secondary mt-0.5;
}
.suggestion-arrow {
@apply w-5 h-5 text-text-tertiary transition-transform duration-micro;
@apply group-hover:translate-x-1;
}
.empty-state {
@apply flex flex-col items-center justify-center py-16 px-6 space-y-4 text-center;
@apply bg-surface-soft rounded-lg;
}
.empty-icon-wrapper {
@apply w-16 h-16 bg-primary-100 dark:bg-primary-900/50 rounded-full flex items-center justify-center;
}
.empty-icon {
@apply w-8 h-8 text-primary-600 dark:text-primary-400;
}
.empty-title {
@apply text-xl font-semibold text-text-primary;
}
.empty-description {
@apply text-text-secondary max-w-md;
}
/* List item animations */
.list-item-enter-active,
.list-item-leave-active {
@apply transition-all duration-medium ease-medium;
}
.list-item-enter-from {
@apply opacity-0 scale-95 translate-y-2;
}
.list-item-leave-to {
@apply opacity-0 scale-95 translate-y-2;
}
.list-item-move {
@apply transition-transform duration-medium ease-medium;
}
</style>

View File

@ -0,0 +1,907 @@
<template>
<div class="onboarding-carousel">
<!-- Progress Header -->
<header class="onboarding-header">
<div class="progress-container">
<div class="progress-bar">
<div class="progress-fill" :style="{ width: `${progressPercentage}%` }"></div>
</div>
<span class="progress-text">{{ currentStep + 1 }} of {{ steps.length }}</span>
</div>
<Button variant="ghost" size="sm" @click="$emit('close')" class="skip-button">
Skip Setup
</Button>
</header>
<!-- Step Content -->
<main class="onboarding-content">
<!-- Step 1: Welcome & Profile -->
<div v-if="currentStep === 0" class="onboarding-step welcome-step">
<div class="step-hero">
<div class="hero-icon">
<BaseIcon name="heroicons:home-modern" class="w-16 h-16 text-primary-500" />
</div>
<Heading :level="2" class="hero-title">Welcome to Your Household Hub!</Heading>
<p class="hero-subtitle">Let's get your household organized in under 60 seconds.</p>
</div>
<div class="profile-setup">
<Card variant="soft" color="primary" class="profile-card">
<div class="profile-header">
<BaseIcon name="heroicons:user-circle" />
<h3>Set Up Your Profile</h3>
</div>
<div class="profile-form">
<div class="avatar-section">
<div class="avatar-preview" :class="{ 'has-image': profileImage }">
<img v-if="profileImage" :src="profileImage" alt="Profile" class="avatar-image" />
<BaseIcon v-else name="heroicons:user" class="avatar-placeholder" />
</div>
<Button variant="outline" size="sm" @click="uploadAvatar">
<BaseIcon name="heroicons:camera" />
Add Photo
</Button>
<input type="file" ref="avatarInput" @change="handleAvatarUpload" accept="image/*"
hidden />
</div>
<div class="form-fields">
<Input v-model="form.name" label="Your Name" placeholder="What should we call you?"
class="name-input" />
<div class="auto-import" v-if="oauthData">
<span class="import-label">Auto-imported from {{ oauthData.provider }}</span>
<Button variant="ghost" size="sm" @click="clearOAuthData">
<BaseIcon name="heroicons:x-mark" />
</Button>
</div>
</div>
</div>
</Card>
</div>
</div>
<!-- Step 2: Household Decision -->
<div v-if="currentStep === 1" class="onboarding-step household-step">
<div class="step-hero">
<Heading :level="2" class="hero-title">Join or Create a Household?</Heading>
<p class="hero-subtitle">Choose how you'd like to get started with household management.</p>
</div>
<div class="household-options">
<div class="option-cards">
<Card class="option-card" :class="{ 'option-selected': householdChoice === 'join' }"
@click="householdChoice = 'join'">
<div class="option-content">
<div class="option-icon">
<BaseIcon name="heroicons:user-plus" class="w-12 h-12 text-success-500" />
</div>
<div class="option-details">
<h3 class="option-title">Join Existing Household</h3>
<p class="option-description">Someone already set up your household? Join with an
invite code.</p>
<div class="option-benefits">
<div class="benefit-item">
<BaseIcon name="heroicons:check-circle" class="benefit-icon" />
<span>Quick setup</span>
</div>
<div class="benefit-item">
<BaseIcon name="heroicons:check-circle" class="benefit-icon" />
<span>Existing data preserved</span>
</div>
</div>
</div>
<div class="option-radio">
<div class="radio-dot" :class="{ 'radio-selected': householdChoice === 'join' }">
</div>
</div>
</div>
</Card>
<Card class="option-card" :class="{ 'option-selected': householdChoice === 'create' }"
@click="householdChoice = 'create'">
<div class="option-content">
<div class="option-icon">
<BaseIcon name="heroicons:plus-circle" class="w-12 h-12 text-primary-500" />
</div>
<div class="option-details">
<h3 class="option-title">Create New Household</h3>
<p class="option-description">Start fresh with a new household that you can invite
others to join.</p>
<div class="option-benefits">
<div class="benefit-item">
<BaseIcon name="heroicons:check-circle" class="benefit-icon" />
<span>Full control</span>
</div>
<div class="benefit-item">
<BaseIcon name="heroicons:check-circle" class="benefit-icon" />
<span>Customize everything</span>
</div>
</div>
</div>
<div class="option-radio">
<div class="radio-dot" :class="{ 'radio-selected': householdChoice === 'create' }">
</div>
</div>
</div>
</Card>
</div>
<!-- Invite Code Input (if joining) -->
<div v-if="householdChoice === 'join'" class="invite-section">
<Card variant="soft" color="success" class="invite-card">
<div class="invite-header">
<BaseIcon name="heroicons:key" />
<h4>Enter Invite Code</h4>
</div>
<div class="invite-input-section">
<Input v-model="inviteCode" label="Invite Code" placeholder="XXXX-XXXX-XXXX"
class="invite-input" @input="formatInviteCode" />
<Button variant="outline" @click="scanQRCode" class="qr-scan-button">
<BaseIcon name="heroicons:qr-code" />
Scan QR
</Button>
</div>
<p class="invite-help">Ask your household member for the invite code or QR code.</p>
</Card>
</div>
<!-- Household Name Input (if creating) -->
<div v-if="householdChoice === 'create'" class="household-name-section">
<Card variant="soft" color="primary" class="household-card">
<div class="household-header">
<BaseIcon name="heroicons:home" />
<h4>Name Your Household</h4>
</div>
<Input v-model="householdName" label="Household Name"
placeholder="e.g., The Smith Family, Apartment 4B..." class="household-input" />
<p class="household-help">Choose a name that everyone in your household will recognize.</p>
</Card>
</div>
</div>
</div>
<!-- Step 3: Feature Showcase -->
<div v-if="currentStep === 2" class="onboarding-step features-step">
<div class="step-hero">
<Heading :level="2" class="hero-title">Here's What You Can Do</Heading>
<p class="hero-subtitle">A quick tour of features that will transform your household management.</p>
</div>
<div class="features-grid">
<Card v-for="feature in features" :key="feature.id" class="feature-card" variant="soft"
:color="feature.color">
<div class="feature-content">
<div class="feature-icon">
<BaseIcon :name="feature.icon" class="w-8 h-8" />
</div>
<div class="feature-details">
<h3 class="feature-title">{{ feature.title }}</h3>
<p class="feature-description">{{ feature.description }}</p>
<div class="feature-example">
<span class="example-label">Example:</span>
<span class="example-text">{{ feature.example }}</span>
</div>
</div>
</div>
</Card>
</div>
<div class="features-footer">
<Card variant="soft" color="warning" class="tip-card">
<div class="tip-content">
<BaseIcon name="heroicons:light-bulb" class="tip-icon" />
<div>
<h4 class="tip-title">Pro Tip</h4>
<p class="tip-text">Start with just one feature - maybe shared shopping lists - and
gradually explore others as your household gets comfortable.</p>
</div>
</div>
</Card>
</div>
</div>
<!-- Step 4: Setup Complete -->
<div v-if="currentStep === 3" class="onboarding-step complete-step">
<div class="completion-hero">
<div class="success-animation">
<BaseIcon name="heroicons:check-circle" class="success-icon" />
</div>
<Heading :level="2" class="completion-title">You're All Set!</Heading>
<p class="completion-subtitle">Your household management hub is ready to use.</p>
</div>
<div class="next-steps">
<Card variant="soft" color="success" class="next-steps-card">
<div class="next-steps-header">
<BaseIcon name="heroicons:rocket-launch" />
<h3>Recommended First Steps</h3>
</div>
<div class="next-steps-list">
<div v-for="step in nextSteps" :key="step.id" class="next-step-item">
<div class="step-number">{{ step.id }}</div>
<div class="step-content">
<span class="step-title">{{ step.title }}</span>
<span class="step-time">{{ step.time }}</span>
</div>
<BaseIcon name="heroicons:arrow-right" class="step-arrow" />
</div>
</div>
</Card>
<div class="completion-actions">
<Button @click="goToDashboard" size="lg" class="dashboard-button">
<template #icon-left>
<BaseIcon name="heroicons:home" />
</template>
Go to Dashboard
</Button>
<Button variant="outline" @click="inviteMembers" size="lg" class="invite-button">
<template #icon-left>
<BaseIcon name="heroicons:user-plus" />
</template>
Invite Household Members
</Button>
</div>
</div>
</div>
</main>
<!-- Navigation Footer -->
<footer class="onboarding-footer">
<div class="footer-actions">
<Button v-if="currentStep > 0" variant="outline" @click="prevStep" :disabled="processing"
class="back-button">
<template #icon-left>
<BaseIcon name="heroicons:arrow-left" />
</template>
Back
</Button>
<div class="spacer"></div>
<Button v-if="currentStep < steps.length - 1" @click="nextStep" :disabled="!canProceed"
:loading="processing" size="lg" class="next-button">
{{ getNextButtonText() }}
<template #icon-right>
<BaseIcon name="heroicons:arrow-right" />
</template>
</Button>
</div>
</footer>
</div>
</template>
<script setup lang="ts">
import { ref, computed, nextTick } from 'vue'
import { useRouter } from 'vue-router'
import { Heading, Button, Card, Input } from '@/components/ui'
import BaseIcon from '@/components/BaseIcon.vue'
import { useAuthStore } from '@/stores/auth'
import { useGroupStore } from '@/stores/groupStore'
import { useNotificationStore } from '@/stores/notifications'
interface Step {
id: string
title: string
canSkip: boolean
}
interface Feature {
id: string
title: string
description: string
example: string
icon: string
color: 'primary' | 'success' | 'warning' | 'error'
}
interface NextStep {
id: number
title: string
time: string
}
const emit = defineEmits<{
close: []
complete: [householdId?: number]
}>()
const router = useRouter()
const auth = useAuthStore()
const groupStore = useGroupStore()
const notifications = useNotificationStore()
// Steps configuration
const steps: Step[] = [
{ id: 'profile', title: 'Profile Setup', canSkip: false },
{ id: 'household', title: 'Household Choice', canSkip: false },
{ id: 'features', title: 'Feature Tour', canSkip: true },
{ id: 'complete', title: 'Setup Complete', canSkip: false }
]
// Current state
const currentStep = ref(0)
const processing = ref(false)
// Form data
const form = ref({
name: auth.user?.name || ''
})
const profileImage = ref<string | null>(auth.user?.avatarUrl || null)
const avatarInput = ref<HTMLInputElement>()
// OAuth data (simulated - would come from actual OAuth flow)
const oauthData = ref<{ provider: string; name: string; image?: string } | null>(
auth.user ? { provider: 'Google', name: auth.user.name } : null
)
// Household setup
const householdChoice = ref<'join' | 'create' | ''>('')
const inviteCode = ref('')
const householdName = ref('')
// Features showcase
const features: Feature[] = [
{
id: 'chores',
title: 'Smart Chore Management',
description: 'Fair assignment, automatic rotation, and point-based rewards keep everyone motivated.',
example: '"Take out trash" auto-assigned to Jake, due tomorrow',
icon: 'heroicons:clipboard-document-check',
color: 'primary'
},
{
id: 'shopping',
title: 'Collaborative Shopping',
description: 'Real-time shared lists prevent duplicate purchases and ensure nothing is forgotten.',
example: 'Sarah adds "Milk" → Jake sees it instantly at the store',
icon: 'heroicons:shopping-cart',
color: 'success'
},
{
id: 'expenses',
title: 'Automatic Bill Splitting',
description: 'Snap receipts, split fairly, and settle debts with built-in payment tracking.',
example: 'Dinner receipt: $60 → Auto-split 3 ways = $20 each',
icon: 'heroicons:receipt-percent',
color: 'warning'
},
{
id: 'activity',
title: 'Household Activity Feed',
description: 'Stay connected with real-time updates on completed tasks and shared expenses.',
example: 'Alex completed "Clean kitchen" +15 points!',
icon: 'heroicons:bell',
color: 'error'
}
]
// Next steps recommendations
const nextSteps: NextStep[] = [
{ id: 1, title: 'Add your first shared shopping list', time: '2 min' },
{ id: 2, title: 'Set up recurring household chores', time: '5 min' },
{ id: 3, title: 'Invite other household members', time: '1 min' }
]
// Computed properties
const progressPercentage = computed(() => {
return ((currentStep.value + 1) / steps.length) * 100
})
const canProceed = computed(() => {
switch (currentStep.value) {
case 0: // Profile setup
return form.value.name.trim().length > 0
case 1: // Household choice
if (householdChoice.value === 'join') {
return inviteCode.value.trim().length >= 12 // Basic validation
}
if (householdChoice.value === 'create') {
return householdName.value.trim().length > 0
}
return householdChoice.value !== ''
case 2: // Features (can always proceed)
return true
case 3: // Complete (no next step)
return false
default:
return false
}
})
// Methods
function nextStep() {
if (currentStep.value < steps.length - 1) {
if (currentStep.value === 1) {
// Process household setup before moving to features
processHouseholdSetup()
} else {
currentStep.value++
}
}
}
function prevStep() {
if (currentStep.value > 0) {
currentStep.value--
}
}
function getNextButtonText() {
switch (currentStep.value) {
case 0:
return 'Continue'
case 1:
return householdChoice.value === 'join' ? 'Join Household' : 'Create Household'
case 2:
return 'Finish Setup'
default:
return 'Next'
}
}
async function processHouseholdSetup() {
processing.value = true
try {
if (householdChoice.value === 'join') {
await joinHousehold()
} else if (householdChoice.value === 'create') {
await createHousehold()
}
currentStep.value++
} catch (error) {
notifications.addNotification({
type: 'error',
message: 'Failed to set up household. Please try again.'
})
} finally {
processing.value = false
}
}
async function joinHousehold() {
// API call to join household with invite code
const cleanCode = inviteCode.value.replace(/[-\s]/g, '')
// Simulate API call
await new Promise(resolve => setTimeout(resolve, 1000))
notifications.addNotification({
type: 'success',
message: 'Successfully joined household!'
})
}
async function createHousehold() {
// API call to create new household
const householdData = {
name: householdName.value.trim(),
created_by: auth.user?.id
}
// Simulate API call
await new Promise(resolve => setTimeout(resolve, 1000))
notifications.addNotification({
type: 'success',
message: `Created "${householdName.value}" household!`
})
}
function uploadAvatar() {
avatarInput.value?.click()
}
function handleAvatarUpload(event: Event) {
const file = (event.target as HTMLInputElement).files?.[0]
if (file) {
const reader = new FileReader()
reader.onload = (e) => {
profileImage.value = e.target?.result as string
}
reader.readAsDataURL(file)
}
}
function clearOAuthData() {
oauthData.value = null
form.value.name = ''
}
function formatInviteCode(event: Event) {
const input = event.target as HTMLInputElement
let value = input.value.replace(/[^A-Z0-9]/gi, '').toUpperCase()
// Format as XXXX-XXXX-XXXX
if (value.length > 0) {
value = value.match(/.{1,4}/g)?.join('-') || value
if (value.length > 14) {
value = value.substring(0, 14)
}
}
inviteCode.value = value
nextTick(() => {
input.value = value
})
}
function scanQRCode() {
// Would implement QR code scanning here
notifications.addNotification({
type: 'info',
message: 'QR code scanning would open camera interface'
})
}
function goToDashboard() {
router.push('/')
emit('complete')
}
function inviteMembers() {
router.push('/household/settings')
emit('complete')
}
</script>
<style scoped>
.onboarding-carousel {
@apply bg-surface-primary rounded-lg w-full max-w-4xl max-h-[90vh] overflow-hidden flex flex-col;
}
/* Header */
.onboarding-header {
@apply flex items-center justify-between p-6 border-b border-border-primary bg-surface-soft;
}
.progress-container {
@apply flex items-center gap-4 flex-1;
}
.progress-bar {
@apply w-64 h-2 bg-border-secondary rounded-full overflow-hidden;
}
.progress-fill {
@apply h-full bg-primary-500 transition-all duration-300 ease-out;
}
.progress-text {
@apply text-sm font-medium text-text-secondary;
}
.skip-button {
@apply text-text-tertiary hover:text-text-secondary;
}
/* Content */
.onboarding-content {
@apply flex-1 overflow-y-auto p-8;
}
.onboarding-step {
@apply max-w-3xl mx-auto space-y-8;
}
/* Step Hero */
.step-hero {
@apply text-center space-y-4;
}
.hero-icon {
@apply flex justify-center mb-6;
}
.hero-title {
@apply text-3xl font-bold text-text-primary;
}
.hero-subtitle {
@apply text-lg text-text-secondary max-w-2xl mx-auto;
}
/* Welcome Step */
.profile-card {
@apply p-6;
}
.profile-header {
@apply flex items-center gap-3 mb-6;
}
.profile-header h3 {
@apply text-xl font-semibold text-text-primary;
}
.profile-form {
@apply flex flex-col md:flex-row gap-6 items-start;
}
.avatar-section {
@apply flex flex-col items-center gap-3;
}
.avatar-preview {
@apply w-24 h-24 rounded-full border-2 border-border-secondary overflow-hidden flex items-center justify-center;
@apply bg-surface-secondary;
}
.has-image {
@apply border-primary-300;
}
.avatar-image {
@apply w-full h-full object-cover;
}
.avatar-placeholder {
@apply w-12 h-12 text-text-tertiary;
}
.form-fields {
@apply flex-1 space-y-4;
}
.name-input {
@apply w-full;
}
.auto-import {
@apply flex items-center gap-2 text-sm text-success-600 bg-success-50 dark:bg-success-950/20 px-3 py-2 rounded-md;
}
/* Household Step */
.household-options {
@apply space-y-6;
}
.option-cards {
@apply grid grid-cols-1 md:grid-cols-2 gap-6;
}
.option-card {
@apply cursor-pointer transition-all duration-200 hover:border-primary-300;
}
.option-selected {
@apply border-primary-500 bg-primary-50 dark:bg-primary-950/20;
}
.option-content {
@apply p-6 flex flex-col items-center text-center space-y-4 relative;
}
.option-icon {
@apply mb-2;
}
.option-details {
@apply space-y-3;
}
.option-title {
@apply text-xl font-semibold text-text-primary;
}
.option-description {
@apply text-text-secondary;
}
.option-benefits {
@apply space-y-2;
}
.benefit-item {
@apply flex items-center gap-2 text-sm text-success-600;
}
.benefit-icon {
@apply w-4 h-4;
}
.option-radio {
@apply absolute top-4 right-4 w-6 h-6 rounded-full border-2 border-border-secondary flex items-center justify-center;
}
.radio-dot {
@apply w-3 h-3 rounded-full bg-transparent transition-colors duration-200;
}
.option-selected .option-radio {
@apply border-primary-500;
}
.option-selected .radio-dot {
@apply bg-primary-500;
}
/* Invite Section */
.invite-section,
.household-name-section {
@apply max-w-md mx-auto;
}
.invite-card,
.household-card {
@apply p-6;
}
.invite-header,
.household-header {
@apply flex items-center gap-3 mb-4;
}
.invite-header h4,
.household-header h4 {
@apply text-lg font-semibold text-text-primary;
}
.invite-input-section {
@apply flex gap-3 mb-3;
}
.invite-input {
@apply flex-1;
}
.qr-scan-button {
@apply shrink-0;
}
.invite-help,
.household-help {
@apply text-sm text-text-secondary;
}
/* Features Step */
.features-grid {
@apply grid grid-cols-1 md:grid-cols-2 gap-6;
}
.feature-card {
@apply p-6;
}
.feature-content {
@apply flex gap-4;
}
.feature-icon {
@apply shrink-0;
}
.feature-details {
@apply space-y-3;
}
.feature-title {
@apply text-lg font-semibold text-text-primary;
}
.feature-description {
@apply text-text-secondary;
}
.feature-example {
@apply space-y-1;
}
.example-label {
@apply text-xs font-medium text-text-tertiary uppercase tracking-wide;
}
.example-text {
@apply text-sm font-mono text-text-secondary bg-surface-secondary px-2 py-1 rounded;
}
.features-footer {
@apply mt-8;
}
.tip-card {
@apply p-4;
}
.tip-content {
@apply flex gap-3;
}
.tip-icon {
@apply w-6 h-6 text-warning-500 shrink-0;
}
.tip-title {
@apply font-semibold text-text-primary mb-1;
}
.tip-text {
@apply text-sm text-text-secondary;
}
/* Complete Step */
.completion-hero {
@apply text-center space-y-6;
}
.success-animation {
@apply flex justify-center;
}
.success-icon {
@apply w-20 h-20 text-success-500 animate-pulse;
}
.completion-title {
@apply text-3xl font-bold text-text-primary;
}
.completion-subtitle {
@apply text-lg text-text-secondary;
}
.next-steps {
@apply space-y-6;
}
.next-steps-card {
@apply p-6;
}
.next-steps-header {
@apply flex items-center gap-3 mb-6;
}
.next-steps-header h3 {
@apply text-xl font-semibold text-text-primary;
}
.next-steps-list {
@apply space-y-4;
}
.next-step-item {
@apply flex items-center gap-4 p-4 bg-surface-primary rounded-lg;
}
.step-number {
@apply w-8 h-8 bg-success-500 text-white rounded-full flex items-center justify-center font-bold text-sm;
}
.step-content {
@apply flex-1 flex flex-col gap-1;
}
.step-title {
@apply font-medium text-text-primary;
}
.step-time {
@apply text-sm text-text-secondary;
}
.step-arrow {
@apply w-5 h-5 text-text-tertiary;
}
.completion-actions {
@apply flex flex-col sm:flex-row gap-4 justify-center;
}
/* Footer */
.onboarding-footer {
@apply p-6 border-t border-border-primary bg-surface-soft;
}
.footer-actions {
@apply flex items-center;
}
.spacer {
@apply flex-1;
}
.next-button {
@apply min-w-[140px];
}
</style>

View File

@ -1,41 +1,647 @@
<template> <template>
<div class="flex items-center space-x-2"> <Card variant="outlined" color="neutral" padding="md" class="quick-chore-add">
<input v-model="choreName" @keyup.enter="handleAdd" type="text" placeholder="Add a quick chore..." <div class="add-container">
class="flex-1 px-3 py-2 rounded-md border border-gray-300 dark:border-neutral-700 focus:outline-none focus:ring-primary-500 focus:border-primary-500 text-sm bg-white dark:bg-neutral-800 text-gray-900 dark:text-gray-100" /> <!-- Quick Add Input -->
<Button variant="solid" size="sm" :disabled="!choreName || isLoading" @click="handleAdd"> <div class="input-group">
Add <div class="input-wrapper">
<Input ref="inputRef" v-model="choreName" :placeholder="placeholderText" :loading="isLoading"
size="md" class="quick-input" @keyup.enter="handleAdd" @focus="handleFocus" @blur="handleBlur"
@input="handleInput">
<template #prefix>
<BaseIcon name="heroicons:plus-20-solid" class="input-icon" />
</template>
</Input>
<!-- Suggestions Dropdown -->
<Transition name="dropdown">
<div v-if="showSuggestions && filteredSuggestions.length > 0" class="suggestions-dropdown">
<div v-for="(suggestion, index) in filteredSuggestions" :key="suggestion.id" :class="[
'suggestion-item',
{ 'selected': selectedSuggestionIndex === index }
]" @click="selectSuggestion(suggestion)" @mouseenter="selectedSuggestionIndex = index">
<div class="suggestion-content">
<div class="suggestion-icon">
<BaseIcon :name="suggestion.icon" />
</div>
<div class="suggestion-text">
<span class="suggestion-title">{{ suggestion.name }}</span>
<span v-if="suggestion.description" class="suggestion-description">
{{ suggestion.description }}
</span>
</div>
<div v-if="suggestion.frequency" class="suggestion-meta">
<span class="frequency-badge">{{ suggestion.frequency }}</span>
</div>
</div>
</div>
</div>
</Transition>
</div>
<!-- Action Buttons -->
<div class="action-buttons">
<Button variant="solid" color="primary" size="md" :disabled="!canAdd" :loading="isLoading"
@click="() => handleAdd()" class="add-button">
<template #icon-left>
<BaseIcon name="heroicons:plus-20-solid" />
</template>
<span class="sr-only md:not-sr-only">Add</span>
</Button>
<!-- Smart Actions -->
<Button v-if="hasSmartSuggestions" variant="soft" color="success" size="md"
@click="showSmartSuggestions = !showSmartSuggestions" class="smart-button"
:class="{ 'active': showSmartSuggestions }">
<template #icon-left>
<BaseIcon name="heroicons:sparkles-20-solid" />
</template>
<span class="sr-only md:not-sr-only">Smart</span>
</Button> </Button>
</div> </div>
</div>
<!-- Smart Suggestions Panel -->
<Transition name="expand">
<div v-if="showSmartSuggestions && smartSuggestions.length > 0" class="smart-panel">
<div class="smart-header">
<div class="smart-title">
<BaseIcon name="heroicons:lightbulb-20-solid" class="title-icon" />
<span>Quick suggestions</span>
</div>
<span class="smart-count">{{ smartSuggestions.length }}</span>
</div>
<div class="smart-suggestions">
<button v-for="suggestion in smartSuggestions" :key="suggestion.id"
@click="selectSuggestion(suggestion)" class="smart-suggestion">
<div class="suggestion-icon">
<BaseIcon :name="suggestion.icon" />
</div>
<div class="suggestion-content">
<span class="suggestion-name">{{ suggestion.name }}</span>
<span v-if="suggestion.description" class="suggestion-desc">
{{ suggestion.description }}
</span>
</div>
<div class="suggestion-action">
<BaseIcon name="heroicons:plus-circle-20-solid" class="plus-icon" />
</div>
</button>
</div>
</div>
</Transition>
<!-- Quick Tips -->
<div v-if="showTips" class="quick-tips">
<div class="tip-content">
<BaseIcon name="heroicons:information-circle-20-solid" class="tip-icon" />
<span class="tip-text">{{ currentTip }}</span>
</div>
</div>
</div>
</Card>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref } from 'vue' import { ref, computed, onMounted, nextTick, watch } from 'vue'
import { useChoreStore } from '@/stores/choreStore' import { useChoreStore } from '@/stores/choreStore'
import Button from '@/components/ui/Button.vue' import { useAuthStore } from '@/stores/auth'
import { useNotificationStore } from '@/stores/notifications'
import { Card, Input, Button } from '@/components/ui'
import BaseIcon from '@/components/BaseIcon.vue'
interface ChoreSuggestion {
id: string
name: string
description?: string
icon: string
frequency?: string
template?: {
description: string
frequency: 'daily' | 'weekly' | 'monthly' | 'one_time'
type: 'personal' | 'group'
}
}
const choreStore = useChoreStore() const choreStore = useChoreStore()
const authStore = useAuthStore()
const notificationStore = useNotificationStore()
// State
const inputRef = ref<InstanceType<typeof Input>>()
const choreName = ref('') const choreName = ref('')
const isLoading = ref(false) const isLoading = ref(false)
const isFocused = ref(false)
const showSuggestions = ref(false)
const showSmartSuggestions = ref(false)
const selectedSuggestionIndex = ref(-1)
const suggestions = ref<ChoreSuggestion[]>([])
const handleAdd = async () => { // Smart contextual tips
if (!choreName.value.trim()) return const tips = [
try { "Try 'Water plants' or 'Take out trash'",
isLoading.value = true "Use frequency hints like 'Weekly laundry'",
await choreStore.create({ "Quick chores get done faster!",
name: choreName.value.trim(), "Daily chores build great habits"
description: '', ]
const currentTipIndex = ref(0)
// Computed
const canAdd = computed(() => choreName.value.trim().length > 0)
const placeholderText = computed(() => {
if (isLoading.value) return 'Adding chore...'
if (isFocused.value) return 'What needs to be done?'
const hour = new Date().getHours()
if (hour < 12) return 'Add a morning task...'
if (hour < 17) return 'Add an afternoon task...'
return 'Add an evening task...'
})
const filteredSuggestions = computed(() => {
if (!choreName.value.trim()) return []
const query = choreName.value.toLowerCase()
return suggestions.value
.filter(suggestion =>
suggestion.name.toLowerCase().includes(query) ||
suggestion.description?.toLowerCase().includes(query)
)
.slice(0, 5)
})
const smartSuggestions = computed(() => {
return suggestions.value
.filter(suggestion => suggestion.template)
.slice(0, 6)
})
const hasSmartSuggestions = computed(() => smartSuggestions.value.length > 0)
const showTips = computed(() =>
!isFocused.value && !choreName.value && !showSmartSuggestions.value
)
const currentTip = computed(() => tips[currentTipIndex.value])
// Generate chore suggestions based on common patterns
const generateSuggestions = (): ChoreSuggestion[] => {
const commonChores: ChoreSuggestion[] = [
// Daily tasks
{
id: 'make-bed',
name: 'Make bed',
description: 'Start the day organized',
icon: 'heroicons:home-20-solid',
frequency: 'Daily',
template: {
description: 'Make bed and tidy bedroom',
frequency: 'daily',
type: 'personal'
}
},
{
id: 'dishes',
name: 'Do dishes',
description: 'Keep kitchen clean',
icon: 'heroicons:beaker-20-solid',
frequency: 'Daily',
template: {
description: 'Wash dishes and clean kitchen',
frequency: 'daily',
type: 'personal'
}
},
{
id: 'take-out-trash',
name: 'Take out trash',
description: 'Empty all waste bins',
icon: 'heroicons:trash-20-solid',
frequency: 'Weekly',
template: {
description: 'Empty trash bins and take to curb',
frequency: 'weekly',
type: 'personal'
}
},
// Weekly tasks
{
id: 'laundry',
name: 'Do laundry',
description: 'Wash, dry, and fold clothes',
icon: 'heroicons:cog-6-tooth-20-solid',
frequency: 'Weekly',
template: {
description: 'Complete laundry cycle and put away clothes',
frequency: 'weekly',
type: 'personal'
}
},
{
id: 'vacuum',
name: 'Vacuum floors',
description: 'Clean all carpeted areas',
icon: 'heroicons:home-modern-20-solid',
frequency: 'Weekly',
template: {
description: 'Vacuum all rooms and common areas',
frequency: 'weekly',
type: 'personal'
}
},
{
id: 'grocery-shopping',
name: 'Grocery shopping',
description: 'Buy weekly groceries',
icon: 'heroicons:shopping-cart-20-solid',
frequency: 'Weekly',
template: {
description: 'Plan meals and buy groceries for the week',
frequency: 'weekly',
type: 'personal'
}
},
// Monthly tasks
{
id: 'clean-bathroom',
name: 'Deep clean bathroom',
description: 'Thorough bathroom cleaning',
icon: 'heroicons:sparkles-20-solid',
frequency: 'Monthly',
template: {
description: 'Deep clean bathroom including scrubbing and disinfecting',
frequency: 'monthly',
type: 'personal'
}
},
{
id: 'organize-closet',
name: 'Organize closet',
description: 'Sort and arrange clothing',
icon: 'heroicons:squares-2x2-20-solid',
frequency: 'Monthly',
template: {
description: 'Sort clothes, donate unused items, organize by season',
frequency: 'monthly',
type: 'personal'
}
},
// One-time tasks
{
id: 'water-plants',
name: 'Water plants',
description: 'Care for houseplants',
icon: 'heroicons:beaker-20-solid',
frequency: 'As needed',
template: {
description: 'Water all houseplants and check for care needs',
frequency: 'one_time', frequency: 'one_time',
type: 'personal', type: 'personal'
}
},
{
id: 'pay-bills',
name: 'Pay bills',
description: 'Handle monthly payments',
icon: 'heroicons:banknotes-20-solid',
frequency: 'Monthly',
template: {
description: 'Review and pay monthly bills and subscriptions',
frequency: 'monthly',
type: 'personal'
}
}
]
return commonChores
}
// Event handlers
const handleFocus = () => {
isFocused.value = true
showSuggestions.value = true
}
const handleBlur = () => {
// Delay to allow suggestion clicks
setTimeout(() => {
isFocused.value = false
showSuggestions.value = false
selectedSuggestionIndex.value = -1
}, 200)
}
const handleInput = () => {
if (choreName.value.trim()) {
showSuggestions.value = true
selectedSuggestionIndex.value = -1
} else {
showSuggestions.value = false
}
}
const handleKeydown = (event: KeyboardEvent) => {
if (!showSuggestions.value || filteredSuggestions.value.length === 0) return
switch (event.key) {
case 'ArrowDown':
event.preventDefault()
selectedSuggestionIndex.value = Math.min(
selectedSuggestionIndex.value + 1,
filteredSuggestions.value.length - 1
)
break
case 'ArrowUp':
event.preventDefault()
selectedSuggestionIndex.value = Math.max(selectedSuggestionIndex.value - 1, -1)
break
case 'Enter':
event.preventDefault()
if (selectedSuggestionIndex.value >= 0) {
selectSuggestion(filteredSuggestions.value[selectedSuggestionIndex.value])
} else {
handleAdd()
}
break
case 'Escape':
showSuggestions.value = false
selectedSuggestionIndex.value = -1
break
}
}
const selectSuggestion = (suggestion: ChoreSuggestion) => {
choreName.value = suggestion.name
showSuggestions.value = false
showSmartSuggestions.value = false
selectedSuggestionIndex.value = -1
// Auto-add if it's a template suggestion
if (suggestion.template) {
nextTick(() => {
handleAdd(suggestion)
})
} else {
// Focus input for potential editing
nextTick(() => {
inputRef.value?.focus?.()
})
}
}
const handleAdd = async (suggestion?: ChoreSuggestion) => {
if (!canAdd.value || isLoading.value) return
isLoading.value = true
try {
const choreData = {
name: choreName.value.trim(),
description: suggestion?.template?.description || '',
frequency: suggestion?.template?.frequency || 'one_time',
type: suggestion?.template?.type || 'personal',
custom_interval_days: undefined, custom_interval_days: undefined,
next_due_date: new Date().toISOString().split('T')[0], next_due_date: new Date().toISOString().split('T')[0],
created_by_id: 0, // backend will override created_by_id: 0, // backend will override
} as any) }
await choreStore.create(choreData as any)
// Success feedback
notificationStore.addNotification({
type: 'success',
message: `✨ "${choreName.value.trim()}" added successfully!`,
})
// Haptic feedback
if ('vibrate' in navigator) {
navigator.vibrate([50, 50, 100])
}
// Reset form
choreName.value = '' choreName.value = ''
} catch (e) { showSmartSuggestions.value = false
// Optionally handle error selectedSuggestionIndex.value = -1
console.error('Failed to create quick chore', e)
// Focus input for continued adding
nextTick(() => {
inputRef.value?.focus?.()
})
} catch (error) {
console.error('Failed to create quick chore', error)
notificationStore.addNotification({
type: 'error',
message: 'Failed to add chore. Please try again.',
})
} finally { } finally {
isLoading.value = false isLoading.value = false
} }
} }
// Lifecycle
onMounted(() => {
suggestions.value = generateSuggestions()
// Cycle tips every 5 seconds
setInterval(() => {
currentTipIndex.value = (currentTipIndex.value + 1) % tips.length
}, 5000)
})
// Add keyboard event listener when focused
watch(isFocused, (focused) => {
if (focused) {
document.addEventListener('keydown', handleKeydown)
} else {
document.removeEventListener('keydown', handleKeydown)
}
})
</script> </script>
<style scoped>
.quick-chore-add {
@apply bg-gradient-to-r from-primary-50 to-primary-50/30 dark:from-primary-900 dark:to-primary-900/30;
@apply border-primary-200 dark:border-primary-800;
@apply transition-all duration-micro hover:shadow-medium;
}
.add-container {
@apply space-y-spacing-sm;
}
.input-group {
@apply flex items-center gap-spacing-sm;
}
.input-wrapper {
@apply flex-1 relative;
}
.quick-input {
@apply transition-all duration-micro;
}
.input-icon {
@apply w-4 h-4 text-text-secondary;
}
.suggestions-dropdown {
@apply absolute top-full left-0 right-0 z-50 mt-1;
@apply bg-surface-primary border border-border-secondary rounded-radius-lg shadow-elevation-medium;
@apply max-h-64 overflow-y-auto;
}
.suggestion-item {
@apply px-spacing-sm py-spacing-xs cursor-pointer transition-colors duration-micro;
@apply border-b border-border-subtle last:border-b-0;
@apply hover:bg-primary-50 dark:hover:bg-primary-950/30;
}
.suggestion-item.selected {
@apply bg-primary-100 dark:bg-primary-900/50 text-primary-900 dark:text-primary-100;
}
.suggestion-content {
@apply flex items-center gap-spacing-sm;
}
.suggestion-icon {
@apply w-8 h-8 bg-surface-secondary rounded-radius-lg flex items-center justify-center flex-shrink-0;
}
.suggestion-text {
@apply flex-1 min-w-0;
}
.suggestion-title {
@apply block font-medium text-text-primary truncate;
}
.suggestion-description {
@apply block text-label-sm text-text-secondary truncate;
}
.suggestion-meta {
@apply flex-shrink-0;
}
.frequency-badge {
@apply px-spacing-xs py-1 bg-success-100 dark:bg-success-900/50 text-success-700 dark:text-success-300;
@apply text-label-xs font-medium rounded-radius-sm;
}
.action-buttons {
@apply flex items-center gap-spacing-xs;
}
.add-button {
@apply transition-transform duration-micro hover:scale-105 active:scale-95;
}
.smart-button {
@apply transition-all duration-micro;
}
.smart-button.active {
@apply bg-success-100 dark:bg-success-900/50 text-success-700 dark:text-success-300;
@apply ring-2 ring-success-200 dark:ring-success-800;
}
.smart-panel {
@apply bg-surface-secondary border border-border-secondary rounded-radius-lg p-spacing-sm;
}
.smart-header {
@apply flex items-center justify-between mb-spacing-sm;
}
.smart-title {
@apply flex items-center gap-spacing-xs text-body-sm font-medium text-text-primary;
}
.title-icon {
@apply w-4 h-4 text-warning-500;
}
.smart-count {
@apply px-spacing-xs py-1 bg-warning-100 dark:bg-warning-900/50 text-warning-700 dark:text-warning-300;
@apply text-label-xs font-medium rounded-radius-full;
}
.smart-suggestions {
@apply grid grid-cols-1 sm:grid-cols-2 gap-spacing-xs;
}
.smart-suggestion {
@apply flex items-center gap-spacing-sm p-spacing-sm;
@apply bg-surface-primary border border-border-subtle rounded-radius-lg;
@apply transition-all duration-micro hover:shadow-elevation-soft hover:scale-[1.02];
@apply cursor-pointer text-left;
}
.suggestion-name {
@apply block font-medium text-text-primary text-body-sm;
}
.suggestion-desc {
@apply block text-label-xs text-text-secondary;
}
.suggestion-action {
@apply flex-shrink-0;
}
.plus-icon {
@apply w-5 h-5 text-primary-500 transition-transform duration-micro group-hover:scale-110;
}
.quick-tips {
@apply flex items-center justify-center py-spacing-xs;
}
.tip-content {
@apply flex items-center gap-spacing-xs text-label-sm text-text-secondary;
}
.tip-icon {
@apply w-4 h-4 text-primary-400;
}
.tip-text {
@apply transition-opacity duration-medium;
}
/* Transitions */
.dropdown-enter-active,
.dropdown-leave-active {
@apply transition-all duration-micro ease-medium;
}
.dropdown-enter-from {
@apply opacity-0 scale-95 translate-y-2;
}
.dropdown-leave-to {
@apply opacity-0 scale-95 translate-y-2;
}
.expand-enter-active,
.expand-leave-active {
@apply transition-all duration-medium ease-medium;
}
.expand-enter-from,
.expand-leave-to {
@apply opacity-0 max-h-0 overflow-hidden;
}
.expand-enter-to,
.expand-leave-from {
@apply opacity-100 max-h-96;
}
</style>

View File

@ -0,0 +1,227 @@
<template>
<Dialog v-model="open" size="md" class="z-50">
<div class="receipt-scanner-modal">
<header class="modal-header">
<BaseIcon name="heroicons:receipt-percent" class="header-icon" />
<Heading :level="3">Scan Receipt</Heading>
<Button variant="ghost" size="sm" @click="closeModal" class="close-btn">
<BaseIcon name="heroicons:x-mark" />
</Button>
</header>
<main class="modal-content">
<div v-if="!imagePreview && !loading" class="upload-section">
<label class="upload-label" @dragover.prevent @drop.prevent="handleDrop">
<BaseIcon name="heroicons:photo" class="upload-icon" />
<span>Drag & drop or click to upload a receipt image (JPG, PNG, WEBP)</span>
<input type="file" accept="image/*" @change="handleFileChange" hidden />
</label>
<p class="upload-help">Max size: 5MB. Only images are supported.</p>
</div>
<div v-if="imagePreview && !loading" class="preview-section">
<img :src="imagePreview" alt="Receipt Preview" class="receipt-preview" />
<Button variant="outline" size="sm" @click="removeImage" class="remove-btn">
<BaseIcon name="heroicons:trash" />
Remove
</Button>
</div>
<div v-if="loading" class="loading-section">
<Spinner size="lg" />
<p class="loading-text">Extracting items from your receipt</p>
</div>
<div v-if="extractedItems.length > 0 && !loading" class="results-section">
<h4 class="results-title">Extracted Items</h4>
<div class="items-list">
<label v-for="(item, idx) in extractedItems" :key="idx" class="item-row">
<input type="checkbox" v-model="selectedItems" :value="item" />
<span>{{ item }}</span>
</label>
</div>
</div>
<Alert v-if="error" type="error" :message="error" class="mt-4" />
</main>
<footer class="modal-footer">
<Button variant="outline" @click="closeModal">Cancel</Button>
<Button color="primary" :disabled="selectedItems.length === 0" @click="addSelectedItems">
<BaseIcon name="heroicons:plus-circle" />
Add Selected Items
</Button>
</footer>
</div>
</Dialog>
</template>
<script setup lang="ts">
import { ref, watch } from 'vue'
import { Dialog, Heading, Button, Alert, Spinner } from '@/components/ui'
import BaseIcon from '@/components/BaseIcon.vue'
import { apiClient } from '@/services/api'
const open = defineModel<boolean>('modelValue', { default: false })
const emit = defineEmits<{ (e: 'add', items: string[]): void }>()
const imageFile = ref<File | null>(null)
const imagePreview = ref<string | null>(null)
const loading = ref(false)
const error = ref<string | null>(null)
const extractedItems = ref<string[]>([])
const selectedItems = ref<string[]>([])
function handleFileChange(e: Event) {
const file = (e.target as HTMLInputElement).files?.[0]
if (file) {
if (!file.type.startsWith('image/')) {
error.value = 'Only image files are supported.'
return
}
if (file.size > 5 * 1024 * 1024) {
error.value = 'File is too large (max 5MB).'
return
}
imageFile.value = file
error.value = null
const reader = new FileReader()
reader.onload = (ev) => {
imagePreview.value = ev.target?.result as string
}
reader.readAsDataURL(file)
extractedItems.value = []
selectedItems.value = []
uploadImage()
}
}
function handleDrop(e: DragEvent) {
const file = e.dataTransfer?.files?.[0]
if (file) {
handleFileChange({ target: { files: [file] } } as any)
}
}
function removeImage() {
imageFile.value = null
imagePreview.value = null
extractedItems.value = []
selectedItems.value = []
error.value = null
}
async function uploadImage() {
if (!imageFile.value) return
loading.value = true
error.value = null
extractedItems.value = []
selectedItems.value = []
try {
const formData = new FormData()
formData.append('image_file', imageFile.value)
const { data } = await apiClient.post('/api/v1/ocr/extract-items', formData, {
headers: { 'Content-Type': 'multipart/form-data' }
})
extractedItems.value = data.extracted_items || []
selectedItems.value = [...extractedItems.value]
} catch (err: any) {
error.value = err?.response?.data?.detail || 'Failed to extract items from receipt.'
} finally {
loading.value = false
}
}
function addSelectedItems() {
emit('add', selectedItems.value)
closeModal()
}
function closeModal() {
open.value = false
removeImage()
error.value = null
}
watch(open, (val) => {
if (!val) removeImage()
})
</script>
<style scoped>
.receipt-scanner-modal {
@apply bg-surface-primary rounded-lg w-full max-w-md overflow-hidden flex flex-col;
}
.modal-header {
@apply flex items-center gap-4 p-6 border-b border-border-primary bg-surface-soft relative;
}
.header-icon {
@apply w-8 h-8 text-primary-500;
}
.close-btn {
@apply absolute right-4 top-4;
}
.modal-content {
@apply flex-1 p-6 space-y-6;
}
.upload-section {
@apply flex flex-col items-center justify-center gap-4;
}
.upload-label {
@apply flex flex-col items-center gap-2 p-8 border-2 border-dashed border-border-secondary rounded-lg cursor-pointer transition-colors duration-200 hover:border-primary-300 hover:bg-surface-soft;
}
.upload-icon {
@apply w-12 h-12 text-text-tertiary;
}
.upload-help {
@apply text-sm text-text-secondary;
}
.preview-section {
@apply flex flex-col items-center gap-3;
}
.receipt-preview {
@apply w-64 max-h-64 object-contain rounded-lg shadow;
}
.remove-btn {
@apply mt-2;
}
.loading-section {
@apply flex flex-col items-center gap-4;
}
.loading-text {
@apply text-text-secondary;
}
.results-section {
@apply mt-4;
}
.results-title {
@apply text-lg font-semibold text-text-primary mb-2;
}
.items-list {
@apply flex flex-col gap-2;
}
.item-row {
@apply flex items-center gap-3 p-2 rounded hover:bg-surface-soft transition-colors duration-150;
}
.modal-footer {
@apply flex items-center justify-end gap-4 p-6 border-t border-border-primary bg-surface-soft;
}
</style>

View File

@ -0,0 +1,339 @@
<template>
<div class="smart-shopping-item" :class="itemStateClasses">
<div class="item-content">
<!-- Drag Handle -->
<div v-if="isOnline && !isCompleted" class="drag-handle">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<circle cx="9" cy="12" r="1"></circle>
<circle cx="9" cy="5" r="1"></circle>
<circle cx="9" cy="19" r="1"></circle>
<circle cx="15" cy="12" r="1"></circle>
<circle cx="15" cy="5" r="1"></circle>
<circle cx="15" cy="19" r="1"></circle>
</svg>
</div>
<!-- Checkbox and Item Info -->
<div class="item-main">
<div class="checkbox-section">
<input type="checkbox" :checked="item.is_complete" @change="handleToggleComplete"
:disabled="!canToggle" class="item-checkbox" />
<div class="item-info">
<h4 class="item-name">{{ item.name }}</h4>
<p v-if="item.quantity" class="item-quantity">Qty: {{ item.quantity }}</p>
<!-- Claim Status -->
<div v-if="claimStatusText" class="claim-status">
<span class="claim-text">{{ claimStatusText }}</span>
<span v-if="claimTimeText" class="claim-time">{{ claimTimeText }}</span>
</div>
</div>
</div>
</div>
<!-- Actions -->
<div class="item-actions">
<!-- Claim/Unclaim Buttons -->
<Button v-if="canClaim" variant="outline" size="sm" @click="$emit('claim', item)" :disabled="!isOnline"
class="claim-btn">
<svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
</svg>
Claim
</Button>
<Button v-if="canUnclaim" variant="outline" size="sm" @click="$emit('unclaim', item)"
:disabled="!isOnline" class="unclaim-btn">
<svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M6 18L18 6M6 6l12 12" />
</svg>
Unclaim
</Button>
<!-- Price Input -->
<div v-if="isCompleted" class="price-section">
<input type="number" :value="item.price || ''" @input="handlePriceUpdate" placeholder="$0.00"
step="0.01" min="0" class="price-input" />
</div>
<!-- Edit Button -->
<Button variant="ghost" size="sm" @click="$emit('edit', item)" class="edit-btn" aria-label="Edit item">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
</svg>
</Button>
</div>
</div>
<!-- Progress Indicator for Claimed Items -->
<div v-if="isClaimedByMe && !isCompleted" class="claimed-indicator">
<div class="claimed-bar"></div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import type { PropType } from 'vue'
import { formatDistanceToNow } from 'date-fns'
import { useAuthStore } from '@/stores/auth'
import type { Item } from '@/types/item'
import type { List } from '@/types/list'
import { Button } from '@/components/ui'
const props = defineProps({
item: {
type: Object as PropType<Item>,
required: true
},
list: {
type: Object as PropType<List>,
required: true
},
isOnline: {
type: Boolean,
default: true
},
isClaimedByMe: {
type: Boolean,
default: false
},
isClaimedByOthers: {
type: Boolean,
default: false
},
isCompleted: {
type: Boolean,
default: false
},
readonly: {
type: Boolean,
default: false
}
})
const emit = defineEmits([
'claim',
'unclaim',
'complete',
'update-price',
'edit'
])
const authStore = useAuthStore()
const currentUser = computed(() => authStore.user)
// Computed properties
const itemStateClasses = computed(() => ({
'is-completed': props.item.is_complete,
'is-claimed-by-me': props.isClaimedByMe,
'is-claimed-by-others': props.isClaimedByOthers,
'is-readonly': props.readonly
}))
const canClaim = computed(() => {
return (
props.list.group_id &&
!props.item.is_complete &&
!props.item.claimed_by_user_id &&
!props.readonly &&
props.isOnline
)
})
const canUnclaim = computed(() => {
return (
props.item.claimed_by_user_id === currentUser.value?.id &&
!props.readonly &&
props.isOnline
)
})
const canToggle = computed(() => {
return (
props.isOnline &&
!props.readonly &&
(props.isClaimedByMe || !props.list.group_id || !props.item.claimed_by_user_id)
)
})
const claimStatusText = computed(() => {
if (!props.item.claimed_by_user_id) return ''
const claimer = props.item.claimed_by_user_id === currentUser.value?.id
? 'You'
: props.item.claimed_by_user?.name || 'Someone'
return `${claimer} claimed this`
})
const claimTimeText = computed(() => {
if (!props.item.claimed_at) return ''
try {
return formatDistanceToNow(new Date(props.item.claimed_at), { addSuffix: true })
} catch (error) {
return ''
}
})
// Event handlers
const handleToggleComplete = (event: Event) => {
const target = event.target as HTMLInputElement
emit('complete', props.item, target.checked)
}
const handlePriceUpdate = (event: Event) => {
const target = event.target as HTMLInputElement
const price = parseFloat(target.value) || 0
emit('update-price', props.item, price)
}
</script>
<style scoped>
.smart-shopping-item {
@apply relative bg-white transition-all duration-200 ease-out;
}
.smart-shopping-item:hover {
@apply bg-neutral-50;
}
.smart-shopping-item.is-completed {
@apply opacity-60;
}
.smart-shopping-item.is-claimed-by-me {
@apply bg-blue-50 border-l-4 border-l-blue-500;
}
.smart-shopping-item.is-claimed-by-others {
@apply bg-amber-50 border-l-4 border-l-amber-500;
}
.smart-shopping-item.is-readonly {
@apply cursor-default;
}
.item-content {
@apply flex items-center p-4 gap-3;
}
.drag-handle {
@apply text-neutral-400 cursor-move;
}
.item-main {
@apply flex-1;
}
.checkbox-section {
@apply flex items-start gap-3;
}
.item-checkbox {
@apply w-5 h-5 rounded border-2 border-neutral-300 text-blue-600 focus:ring-blue-500 focus:ring-2 focus:ring-offset-2;
@apply transition-colors duration-200;
}
.item-checkbox:checked {
@apply bg-blue-600 border-blue-600;
}
.item-info {
@apply flex-1;
}
.item-name {
@apply font-medium text-neutral-900 mb-1;
}
.is-completed .item-name {
@apply line-through text-neutral-500;
}
.item-quantity {
@apply text-sm text-neutral-600 mb-1;
}
.claim-status {
@apply flex items-center gap-2 text-xs;
}
.claim-text {
@apply font-medium text-neutral-700;
}
.is-claimed-by-me .claim-text {
@apply text-blue-700;
}
.is-claimed-by-others .claim-text {
@apply text-amber-700;
}
.claim-time {
@apply text-neutral-500;
}
.item-actions {
@apply flex items-center gap-2;
}
.claim-btn,
.unclaim-btn {
@apply whitespace-nowrap;
}
.claim-btn {
@apply border-blue-300 text-blue-700 hover:bg-blue-50;
}
.unclaim-btn {
@apply border-amber-300 text-amber-700 hover:bg-amber-50;
}
.price-section {
@apply flex items-center;
}
.price-input {
@apply w-20 px-2 py-1 text-sm border border-neutral-300 rounded focus:ring-2 focus:ring-blue-500 focus:border-blue-500;
@apply transition-colors duration-200;
}
.edit-btn {
@apply text-neutral-500 hover:text-neutral-700;
}
.claimed-indicator {
@apply absolute bottom-0 left-0 right-0 h-1 bg-gradient-to-r from-blue-500 to-blue-600;
@apply overflow-hidden;
}
.claimed-bar {
@apply h-full w-full bg-blue-500 animate-pulse;
}
/* Animation for claim status changes */
.smart-shopping-item {
@apply transition-all duration-300 ease-out;
}
@keyframes claim-highlight {
0% {
@apply bg-blue-100;
}
100% {
@apply bg-blue-50;
}
}
.is-claimed-by-me {
animation: claim-highlight 0.5s ease-out;
}
</style>

View File

@ -0,0 +1,828 @@
<template>
<div class="smart-shopping-list">
<!-- List Header with Smart Actions -->
<div class="list-header">
<div class="header-info">
<h2 class="list-title">{{ list.name }}</h2>
<div class="list-stats">
<span class="item-count">{{ incompletedItems.length }} items</span>
<span v-if="claimedByOthersCount > 0" class="claimed-indicator">
{{ claimedByOthersCount }} claimed by others
</span>
</div>
</div>
<!-- Quick Actions -->
<div class="header-actions">
<Button v-if="hasCompletedItemsWithoutPrices" variant="soft" size="sm" @click="showReceiptScanner"
class="receipt-scanner-btn">
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M3 9a2 2 0 012-2h.93a2 2 0 001.664-.89l.812-1.22A2 2 0 0110.07 4h3.86a2 2 0 011.664.89l.812 1.22A2 2 0 0018.07 7H19a2 2 0 012 2v9a2 2 0 01-2 2H5a2 2 0 01-2-2V9z" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M15 13a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
Scan Receipt
</Button>
<Button v-if="canCreateExpense" variant="primary" size="sm" @click="showExpensePrompt"
class="create-expense-btn">
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
</svg>
Create Expense
</Button>
</div>
</div>
<!-- Conflict Alerts -->
<TransitionGroup name="alert" tag="div" class="space-y-3 mb-4">
<Alert v-for="conflict in activeConflicts" :key="conflict.id" type="warning" :message="conflict.message"
class="conflict-alert" @dismiss="dismissConflict(conflict.id)" />
</TransitionGroup>
<!-- Smart Shopping Items -->
<div class="shopping-items">
<!-- Unclaimed Items -->
<div v-if="unclaimedItems.length > 0" class="item-group">
<h3 class="group-title">
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M16 11V7a4 4 0 00-8 0v4M5 9h14l1 12H4L5 9z" />
</svg>
Available to Shop ({{ unclaimedItems.length }})
</h3>
<div class="items-list">
<SmartShoppingItem v-for="item in unclaimedItems" :key="item.id" :item="item" :list="list"
:is-online="isOnline" @claim="handleClaimItem" @complete="handleCompleteItem"
@update-price="handleUpdatePrice" @edit="$emit('edit-item', item)" />
</div>
</div>
<!-- My Claimed Items -->
<div v-if="myClaimedItems.length > 0" class="item-group">
<h3 class="group-title claimed-by-me">
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M16 11V7a4 4 0 00-8 0v4M5 9h14l1 12H4L5 9z" />
</svg>
My Items ({{ myClaimedItems.length }})
</h3>
<div class="items-list">
<SmartShoppingItem v-for="item in myClaimedItems" :key="item.id" :item="item" :list="list"
:is-online="isOnline" :is-claimed-by-me="true" @unclaim="handleUnclaimItem"
@complete="handleCompleteItem" @update-price="handleUpdatePrice"
@edit="$emit('edit-item', item)" />
</div>
</div>
<!-- Others' Claimed Items -->
<div v-if="othersClaimedItems.length > 0" class="item-group">
<h3 class="group-title claimed-by-others">
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197m13.5-9a2.25 2.25 0 11-4.5 0 2.25 2.25 0 014.5 0z" />
</svg>
Claimed by Others ({{ othersClaimedItems.length }})
</h3>
<div class="items-list">
<SmartShoppingItem v-for="item in othersClaimedItems" :key="item.id" :item="item" :list="list"
:is-online="isOnline" :is-claimed-by-others="true" @edit="$emit('edit-item', item)" readonly />
</div>
</div>
<!-- Completed Items -->
<div v-if="completedItems.length > 0" class="item-group">
<h3 class="group-title completed">
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
Completed ({{ completedItems.length }})
</h3>
<div class="items-list completed-items">
<SmartShoppingItem v-for="item in completedItems" :key="item.id" :item="item" :list="list"
:is-online="isOnline" :is-completed="true" @update-price="handleUpdatePrice"
@edit="$emit('edit-item', item)" />
</div>
</div>
</div>
<!-- Undo Toast -->
<Transition name="undo-toast">
<div v-if="undoAction" class="undo-toast">
<div class="undo-content">
<span class="undo-message">{{ undoAction.message }}</span>
</div>
<Button variant="ghost" size="sm" @click="performUndo" class="undo-button">
Undo
</Button>
</div>
</Transition>
<!-- Receipt Scanner Modal -->
<Dialog v-model="showReceiptScanner" title="Scan Receipt">
<div class="receipt-scanner">
<div class="scanner-area">
<input ref="fileInput" type="file" accept="image/*" capture="environment"
@change="handleReceiptUpload" class="hidden" />
<Button @click="triggerFileUpload" variant="outline" class="upload-btn">
<svg class="w-6 h-6 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
</svg>
Choose Receipt Image
</Button>
</div>
<div v-if="scanningReceipt" class="scanning-status">
<Spinner size="sm" />
<span>Scanning receipt...</span>
</div>
<div v-if="scannedData" class="scanned-results">
<h4 class="results-title">Detected Items & Prices:</h4>
<div class="detected-items">
<div v-for="(detectedItem, index) in scannedData.items" :key="index" class="detected-item">
<span class="item-name">{{ detectedItem.name }}</span>
<span class="item-price">${{ detectedItem.price.toFixed(2) }}</span>
<Button size="sm" variant="outline" @click="applyScannedPrice(detectedItem)">
Apply
</Button>
</div>
</div>
</div>
</div>
</Dialog>
<!-- Expense Creation Prompt -->
<Dialog v-model="showExpensePrompt" title="Create Expense from Shopping">
<div class="expense-prompt">
<p class="prompt-message">
You have {{ completedItemsWithPrices.length }} completed items with prices totaling
<strong>${{ totalCompletedValue.toFixed(2) }}</strong>.
Would you like to create an expense for this shopping trip?
</p>
<div class="expense-options">
<div class="option-card" @click="createExpenseType = 'equal'">
<input type="radio" id="equal-split" v-model="createExpenseType" value="equal"
class="sr-only" />
<label for="equal-split" class="option-label">
<h4>Equal Split</h4>
<p>Split total amount equally among all group members</p>
</label>
</div>
<div class="option-card" @click="createExpenseType = 'item-based'">
<input type="radio" id="item-split" v-model="createExpenseType" value="item-based"
class="sr-only" />
<label for="item-split" class="option-label">
<h4>Item-Based Split</h4>
<p>Each person pays for items they added to the list</p>
</label>
</div>
</div>
<div class="prompt-actions">
<Button variant="outline" @click="showExpensePrompt = false">
Not Now
</Button>
<Button variant="primary" @click="createExpenseFromShopping">
Create Expense
</Button>
</div>
</div>
</Dialog>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
import type { PropType } from 'vue'
import { useListsStore } from '@/stores/listsStore'
import { useAuthStore } from '@/stores/auth'
import { useNotificationStore } from '@/stores/notifications'
import type { Item } from '@/types/item'
import type { List } from '@/types/list'
// UI Components
import { Button, Alert, Dialog, Spinner } from '@/components/ui'
import SmartShoppingItem from './SmartShoppingItem.vue'
interface UndoAction {
id: string
type: 'claim' | 'unclaim' | 'complete' | 'price-update'
message: string
originalState: any
item: Item
timeRemaining: number
}
interface Conflict {
id: string
message: string
type: 'simultaneous-claim' | 'concurrent-edit' | 'price-conflict'
}
const props = defineProps({
list: {
type: Object as PropType<List>,
required: true
},
items: {
type: Array as PropType<Item[]>,
required: true
},
isOnline: {
type: Boolean,
default: true
}
})
const emit = defineEmits([
'edit-item',
'create-expense'
])
// Stores
const listsStore = useListsStore()
const authStore = useAuthStore()
const notificationStore = useNotificationStore()
// Reactive state
const undoAction = ref<UndoAction | null>(null)
const undoProgress = ref(100)
const activeConflicts = ref<Conflict[]>([])
const showReceiptScanner = ref(false)
const showExpensePrompt = ref(false)
const scanningReceipt = ref(false)
const scannedData = ref(null)
const createExpenseType = ref<'equal' | 'item-based'>('equal')
const fileInput = ref<HTMLInputElement>()
// Computed properties for intelligent item grouping
const currentUser = computed(() => authStore.user)
const incompletedItems = computed(() =>
props.items.filter(item => !item.is_complete)
)
const completedItems = computed(() =>
props.items.filter(item => item.is_complete)
)
const unclaimedItems = computed(() =>
incompletedItems.value.filter(item => !item.claimed_by_user_id)
)
const myClaimedItems = computed(() =>
incompletedItems.value.filter(item =>
item.claimed_by_user_id === currentUser.value?.id
)
)
const othersClaimedItems = computed(() =>
incompletedItems.value.filter(item =>
item.claimed_by_user_id && item.claimed_by_user_id !== currentUser.value?.id
)
)
const claimedByOthersCount = computed(() => othersClaimedItems.value.length)
const completedItemsWithPrices = computed(() =>
completedItems.value.filter(item => {
const price = typeof item.price === 'string' ? parseFloat(item.price) : (item.price || 0)
return price > 0
})
)
const hasCompletedItemsWithoutPrices = computed(() =>
completedItems.value.some(item => {
const price = typeof item.price === 'string' ? parseFloat(item.price) : (item.price || 0)
return price <= 0
})
)
const canCreateExpense = computed(() =>
completedItemsWithPrices.value.length > 0 && props.list.group_id
)
const totalCompletedValue = computed(() =>
completedItemsWithPrices.value.reduce((sum, item) => {
const price = typeof item.price === 'string' ? parseFloat(item.price) : (item.price || 0)
return sum + price
}, 0)
)
// Item action handlers with optimistic updates and undo functionality
const handleClaimItem = async (item: Item) => {
if (!props.isOnline) {
notificationStore.addNotification({
type: 'error',
message: 'Cannot claim items while offline'
})
return
}
// Check for simultaneous claims
if (item.claimed_by_user_id) {
addConflict({
id: `claim-conflict-${item.id}`,
type: 'simultaneous-claim',
message: `"${item.name}" was just claimed by ${item.claimed_by_user?.name}. Refresh to see latest state.`
})
return
}
const originalState = { ...item }
// Optimistic update
item.claimed_by_user_id = currentUser.value?.id ? Number(currentUser.value.id) : null
item.claimed_by_user = currentUser.value || null
item.claimed_at = new Date().toISOString()
try {
await listsStore.claimItem(item.id)
// Add undo action
addUndoAction({
id: `claim-${item.id}`,
type: 'claim',
message: `Claimed "${item.name}"`,
originalState,
item,
timeRemaining: 10000
})
// Haptic feedback
if ('vibrate' in navigator) {
navigator.vibrate(50)
}
} catch (error) {
// Rollback optimistic update
Object.assign(item, originalState)
notificationStore.addNotification({
type: 'error',
message: 'Failed to claim item. Please try again.'
})
}
}
const handleUnclaimItem = async (item: Item) => {
const originalState = { ...item }
// Optimistic update
item.claimed_by_user_id = null
item.claimed_by_user = null
item.claimed_at = null
try {
await listsStore.unclaimItem(item.id)
addUndoAction({
id: `unclaim-${item.id}`,
type: 'unclaim',
message: `Unclaimed "${item.name}"`,
originalState,
item,
timeRemaining: 10000
})
} catch (error) {
Object.assign(item, originalState)
notificationStore.addNotification({
type: 'error',
message: 'Failed to unclaim item. Please try again.'
})
}
}
const handleCompleteItem = async (item: Item, completed: boolean) => {
const originalState = { ...item }
// Optimistic update
item.is_complete = completed
try {
// Call the actual API through the store
await listsStore.updateItem(item.id, { is_complete: completed })
if (completed) {
addUndoAction({
id: `complete-${item.id}`,
type: 'complete',
message: `Completed "${item.name}"`,
originalState,
item,
timeRemaining: 10000
})
// Celebration feedback
if ('vibrate' in navigator) {
navigator.vibrate([100, 50, 100])
}
}
} catch (error) {
Object.assign(item, originalState)
notificationStore.addNotification({
type: 'error',
message: 'Failed to update item. Please try again.'
})
}
}
const handleUpdatePrice = async (item: Item, price: number) => {
const originalState = { ...item }
// Optimistic update
item.price = price
try {
await listsStore.updateItem(item.id, { price })
addUndoAction({
id: `price-${item.id}`,
type: 'price-update',
message: `Updated price for "${item.name}" to $${price.toFixed(2)}`,
originalState,
item,
timeRemaining: 10000
})
} catch (error) {
Object.assign(item, originalState)
notificationStore.addNotification({
type: 'error',
message: 'Failed to update price. Please try again.'
})
}
}
// Undo system
const addUndoAction = (action: UndoAction) => {
// Clear any existing undo action
if (undoAction.value) {
clearUndoTimer()
}
undoAction.value = action
undoProgress.value = 100
// Start countdown timer
const startTime = Date.now()
const timer = setInterval(() => {
const elapsed = Date.now() - startTime
const remaining = Math.max(0, action.timeRemaining - elapsed)
undoProgress.value = (remaining / action.timeRemaining) * 100
if (remaining <= 0) {
clearInterval(timer)
undoAction.value = null
}
}, 50)
// Auto-clear after timeout
setTimeout(() => {
if (undoAction.value?.id === action.id) {
undoAction.value = null
}
}, action.timeRemaining)
}
const performUndo = async () => {
if (!undoAction.value) return
const action = undoAction.value
undoAction.value = null
try {
// Restore original state
Object.assign(action.item, action.originalState)
// Make the API call to revert
switch (action.type) {
case 'claim':
await listsStore.unclaimItem(action.item.id)
break
case 'unclaim':
await listsStore.claimItem(action.item.id)
break
case 'complete':
await listsStore.updateItem(action.item.id, { is_complete: action.originalState.is_complete })
break
case 'price-update':
await listsStore.updateItem(action.item.id, { price: action.originalState.price })
break
}
notificationStore.addNotification({
type: 'success',
message: 'Action undone successfully'
})
} catch (error) {
notificationStore.addNotification({
type: 'error',
message: 'Failed to undo action. Please try again.'
})
}
}
const clearUndoTimer = () => {
undoAction.value = null
}
// Conflict management
const addConflict = (conflict: Conflict) => {
activeConflicts.value.push(conflict)
// Auto-dismiss after 10 seconds
setTimeout(() => {
dismissConflict(conflict.id)
}, 10000)
}
const dismissConflict = (conflictId: string) => {
activeConflicts.value = activeConflicts.value.filter(c => c.id !== conflictId)
}
// Receipt scanning
const openReceiptScanner = () => {
showReceiptScanner.value = true
}
const triggerFileUpload = () => {
fileInput.value?.click()
}
const handleReceiptUpload = async (event: Event) => {
const target = event.target as HTMLInputElement
const file = target.files?.[0]
if (!file) return
scanningReceipt.value = true
try {
// This would integrate with Tesseract.js or a backend OCR service
// For now, we'll simulate the scanning process
await new Promise(resolve => setTimeout(resolve, 2000))
// Mock scanned data - in reality this would come from OCR
scannedData.value = {
items: [
{ name: 'Milk', price: 4.99 },
{ name: 'Bread', price: 3.50 },
{ name: 'Eggs', price: 6.99 }
]
} as any
} catch (error) {
notificationStore.addNotification({
type: 'error',
message: 'Failed to scan receipt. Please try again.'
})
} finally {
scanningReceipt.value = false
}
}
const applyScannedPrice = (detectedItem: any) => {
// Find matching item in the list and apply the price
const matchingItem = completedItems.value.find(item =>
item.name.toLowerCase().includes(detectedItem.name.toLowerCase()) ||
detectedItem.name.toLowerCase().includes(item.name.toLowerCase())
)
if (matchingItem) {
handleUpdatePrice(matchingItem, detectedItem.price)
notificationStore.addNotification({
type: 'success',
message: `Applied $${detectedItem.price.toFixed(2)} to "${matchingItem.name}"`
})
}
}
// Expense creation
const promptExpenseCreation = () => {
showExpensePrompt.value = true
}
const createExpenseFromShopping = () => {
emit('create-expense', {
type: createExpenseType.value,
items: completedItemsWithPrices.value,
total: totalCompletedValue.value
})
showExpensePrompt.value = false
notificationStore.addNotification({
type: 'success',
message: 'Expense creation initiated'
})
}
// Lifecycle
onMounted(() => {
// Connect to WebSocket for real-time updates if online
if (props.isOnline && props.list.id) {
listsStore.connectWebSocket(props.list.id, authStore.token)
}
})
onUnmounted(() => {
listsStore.disconnectWebSocket()
clearUndoTimer()
})
// Watch for online status changes
watch(() => props.isOnline, (isOnline) => {
if (isOnline && props.list.id) {
listsStore.connectWebSocket(props.list.id, authStore.token)
} else {
listsStore.disconnectWebSocket()
}
})
</script>
<style scoped>
.smart-shopping-list {
@apply max-w-4xl mx-auto space-y-6;
}
.list-header {
@apply flex items-start justify-between p-4 bg-white rounded-lg shadow-soft border border-neutral-200;
}
.header-info {
@apply flex-1;
}
.list-title {
@apply text-xl font-semibold text-neutral-900 mb-1;
}
.list-stats {
@apply flex items-center space-x-4 text-sm text-neutral-600;
}
.claimed-indicator {
@apply px-2 py-1 bg-amber-100 text-amber-800 rounded-full text-xs font-medium;
}
.header-actions {
@apply flex items-center space-x-3;
}
.item-group {
@apply bg-white rounded-lg shadow-soft border border-neutral-200 overflow-hidden;
}
.group-title {
@apply flex items-center px-4 py-3 bg-neutral-50 border-b border-neutral-200 font-medium text-neutral-900;
}
.group-title.claimed-by-me {
@apply bg-blue-50 text-blue-900;
}
.group-title.claimed-by-others {
@apply bg-amber-50 text-amber-900;
}
.group-title.completed {
@apply bg-green-50 text-green-900;
}
.items-list {
@apply divide-y divide-neutral-200;
}
.completed-items {
@apply opacity-75;
}
.conflict-alert {
@apply border-l-4 border-l-amber-500;
}
.undo-toast {
@apply fixed bottom-4 right-4 z-50 bg-neutral-900 text-white rounded-lg shadow-floating p-4 min-w-80;
@apply flex items-center justify-between;
}
.undo-content {
@apply flex-1 relative;
}
.undo-message {
@apply text-sm font-medium;
}
.undo-progress {
@apply absolute bottom-0 left-0 h-0.5 bg-blue-500 transition-all duration-50 ease-linear;
}
.undo-button {
@apply ml-4 text-blue-400 hover:text-blue-300;
}
.receipt-scanner {
@apply space-y-6;
}
.scanner-area {
@apply flex flex-col items-center justify-center p-8 border-2 border-dashed border-neutral-300 rounded-lg;
}
.scanning-status {
@apply flex items-center justify-center space-x-3 p-4 bg-blue-50 rounded-lg;
}
.scanned-results {
@apply space-y-4;
}
.results-title {
@apply font-semibold text-neutral-900;
}
.detected-items {
@apply space-y-2;
}
.detected-item {
@apply flex items-center justify-between p-3 bg-neutral-50 rounded-lg;
}
.item-name {
@apply font-medium text-neutral-900;
}
.item-price {
@apply text-green-600 font-mono;
}
.expense-prompt {
@apply space-y-6;
}
.prompt-message {
@apply text-neutral-700 leading-relaxed;
}
.expense-options {
@apply space-y-3;
}
.option-card {
@apply border border-neutral-200 rounded-lg p-4 cursor-pointer transition-all duration-200;
@apply hover:border-blue-300 hover:bg-blue-50;
}
.option-card:has(input:checked) {
@apply border-blue-500 bg-blue-50;
}
.option-label {
@apply cursor-pointer;
}
.option-label h4 {
@apply font-semibold text-neutral-900 mb-1;
}
.option-label p {
@apply text-sm text-neutral-600;
}
.prompt-actions {
@apply flex justify-end space-x-3;
}
/* Transitions */
.alert-enter-active,
.alert-leave-active {
@apply transition-all duration-300 ease-out;
}
.alert-enter-from,
.alert-leave-to {
@apply opacity-0 transform translate-y-2;
}
.undo-toast-enter-active,
.undo-toast-leave-active {
@apply transition-all duration-300 ease-out;
}
.undo-toast-enter-from,
.undo-toast-leave-to {
@apply opacity-0 transform translate-x-full;
}
</style>

View File

@ -0,0 +1,19 @@
import { mount } from '@vue/test-utils'
import { describe, it, expect, vi } from 'vitest'
import InviteManager from '../InviteManager.vue'
// Mock clipboard
vi.stubGlobal('navigator', {
clipboard: {
writeText: vi.fn().mockResolvedValue(undefined),
},
})
describe('InviteManager.vue', () => {
it('shows generate button when no invite', () => {
const wrapper = mount(InviteManager, {
props: { groupId: 1 },
})
expect(wrapper.text()).toContain('Generate Invite Code')
})
})

View File

@ -0,0 +1,16 @@
import { mount } from '@vue/test-utils'
import { describe, it, expect } from 'vitest'
import ReceiptScannerModal from '../ReceiptScannerModal.vue'
describe('ReceiptScannerModal.vue', () => {
it('rejects non-image files', async () => {
const wrapper = mount(ReceiptScannerModal)
await wrapper.setProps({ modelValue: true })
const fileInput = wrapper.find('input[type="file"]')
const fakeFile = new File(['test'], 'test.pdf', { type: 'application/pdf' })
await fileInput.trigger('change', { target: { files: [fakeFile] } })
expect(wrapper.text()).toContain('Only image files are supported')
})
})

View File

@ -1,35 +1,287 @@
<template> <template>
<div class="rounded-lg bg-white dark:bg-dark shadow"> <div class="activity-feed">
<h2 class="font-bold p-4 border-b border-gray-200 dark:border-neutral-700">Activity Feed</h2> <!-- Feed Header -->
<div v-if="store.isLoading && store.activities.length === 0" class="p-4 text-center text-gray-500"> <div class="feed-header">
Loading activity... <div class="header-content">
<h2 class="feed-title">Recent Activity</h2>
<div class="header-stats">
<span v-if="!store.isLoading" class="activity-count">
{{ totalActivitiesText }}
</span>
</div> </div>
<div v-else-if="store.error" class="p-4 text-center text-danger">
{{ store.error }}
</div> </div>
<div v-else-if="store.activities.length > 0" class="divide-y divide-gray-200 dark:divide-neutral-700 px-4"> <div class="header-actions">
<ActivityItem v-for="activity in store.activities" :key="activity.id" :activity="activity" /> <button v-if="store.activities.length > 0" class="view-all-btn" @click="handleViewAll"
<div ref="loadMoreSentinel"></div> aria-label="View all activities">
<span class="material-icons">open_in_new</span>
</button>
</div>
</div>
<!-- Feed Content -->
<div class="feed-content">
<!-- Loading State -->
<div v-if="store.isLoading && store.activities.length === 0" class="feed-loading">
<div class="loading-content">
<div class="loading-spinner">
<svg class="animate-spin h-6 w-6 text-primary-500" fill="none" viewBox="0 0 24 24">
<circle cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" class="opacity-25">
</circle>
<path fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
class="opacity-75"></path>
</svg>
</div>
<p class="loading-text">Loading recent activity...</p>
</div>
</div>
<!-- Error State -->
<div v-else-if="store.error" class="feed-error">
<div class="error-content">
<span class="material-icons error-icon">error_outline</span>
<p class="error-message">{{ store.error }}</p>
<button class="retry-btn" @click="handleRetry">
Try Again
</button>
</div>
</div>
<!-- Activity Groups -->
<div v-else-if="activityGroups.length > 0" class="activity-groups">
<div v-for="group in activityGroups" :key="group.key" class="activity-group"
:class="{ 'new-group': group.isNew }">
<!-- Group Header -->
<div class="group-header">
<time class="group-time">{{ group.timeLabel }}</time>
<div class="group-stats">
<span class="group-count">{{ group.activities.length }} {{ group.activities.length === 1 ?
'update' : 'updates' }}</span>
</div>
</div>
<!-- Group Activities -->
<div class="group-activities">
<ActivityItem v-for="activity in group.activities" :key="activity.id" :activity="activity"
:is-new="isNewActivity(activity)" @click="handleActivityClick(activity)" />
</div>
</div>
<!-- Load More Sentinel -->
<div ref="loadMoreSentinel" class="load-more-sentinel" :class="{ 'loading': store.isLoading }">
<div v-if="store.isLoading" class="load-more-spinner">
<svg class="animate-spin h-4 w-4 text-neutral-400" fill="none" viewBox="0 0 24 24">
<circle cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" class="opacity-25">
</circle>
<path fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
class="opacity-75"></path>
</svg>
<span class="load-more-text">Loading more...</span>
</div>
</div>
</div>
<!-- Empty State -->
<div v-else class="feed-empty">
<div class="empty-content">
<div class="empty-illustration">
<span class="material-icons empty-icon">timeline</span>
</div>
<h3 class="empty-title">No activity yet</h3>
<p class="empty-description">
Activity from your household will appear here as members complete chores, add expenses, and
update lists.
</p>
<div class="empty-actions">
<button class="empty-action-btn primary" @click="handleCreateSomething">
<span class="material-icons">add</span>
Get Started
</button>
</div>
</div>
</div>
</div>
<!-- Social Proof Footer -->
<div v-if="socialProofMessage" class="social-proof">
<div class="social-proof-content">
<span class="material-icons social-proof-icon">people</span>
<p class="social-proof-text">{{ socialProofMessage }}</p>
</div> </div>
<div v-else class="p-4 text-center text-gray-500">
No recent activity.
</div> </div>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { onMounted, onUnmounted, ref, watch } from 'vue' import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import { useIntersectionObserver } from '@vueuse/core' import { useIntersectionObserver } from '@vueuse/core'
import { formatDistanceToNow, isToday, isYesterday, format, startOfDay, isSameDay } from 'date-fns'
import { useActivityStore } from '@/stores/activityStore' import { useActivityStore } from '@/stores/activityStore'
import { useGroupStore } from '@/stores/groupStore' import { useGroupStore } from '@/stores/groupStore'
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth'
import { useRouter } from 'vue-router'
import type { Activity } from '@/types/activity'
import ActivityItem from './ActivityItem.vue' import ActivityItem from './ActivityItem.vue'
// Props
interface Props {
maxHeight?: string
showSocialProof?: boolean
limitVisible?: number
}
const props = withDefaults(defineProps<Props>(), {
maxHeight: '400px',
showSocialProof: true,
limitVisible: 20,
})
// Emits
const emit = defineEmits<{
'activity-click': [activity: Activity]
'create-action': []
}>()
// Store and router
const store = useActivityStore() const store = useActivityStore()
const groupStore = useGroupStore() const groupStore = useGroupStore()
const authStore = useAuthStore() const authStore = useAuthStore()
const loadMoreSentinel = ref<HTMLDivElement | null>(null) const router = useRouter()
// Refs
const loadMoreSentinel = ref<HTMLDivElement | null>(null)
const recentActivityIds = ref<Set<string>>(new Set())
// Activity grouping logic
interface ActivityGroup {
key: string
timeLabel: string
activities: Activity[]
isNew?: boolean
}
const activityGroups = computed<ActivityGroup[]>(() => {
const activities = store.activities.slice(0, props.limitVisible)
if (!activities.length) return []
const groups: ActivityGroup[] = []
const groupMap = new Map<string, Activity[]>()
// Group activities by day
activities.forEach(activity => {
const activityDate = new Date(activity.timestamp)
let groupKey: string
let timeLabel: string
if (isToday(activityDate)) {
groupKey = 'today'
timeLabel = 'Today'
} else if (isYesterday(activityDate)) {
groupKey = 'yesterday'
timeLabel = 'Yesterday'
} else {
groupKey = format(activityDate, 'yyyy-MM-dd')
timeLabel = format(activityDate, 'EEEE, MMM d')
}
if (!groupMap.has(groupKey)) {
groupMap.set(groupKey, [])
}
groupMap.get(groupKey)!.push(activity)
})
// Convert to groups array and sort
const sortedKeys = Array.from(groupMap.keys()).sort((a, b) => {
if (a === 'today') return -1
if (b === 'today') return 1
if (a === 'yesterday') return -1
if (b === 'yesterday') return 1
return b.localeCompare(a) // Recent dates first
})
sortedKeys.forEach(key => {
const groupActivities = groupMap.get(key)!
groups.push({
key,
timeLabel: key === 'today' ? 'Today' :
key === 'yesterday' ? 'Yesterday' :
format(new Date(groupActivities[0].timestamp), 'EEEE, MMM d'),
activities: groupActivities.sort((a, b) =>
new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime()
),
isNew: key === 'today' && hasNewActivities(groupActivities)
})
})
return groups
})
// Helper functions
const hasNewActivities = (activities: Activity[]): boolean => {
return activities.some(activity => isNewActivity(activity))
}
const isNewActivity = (activity: Activity): boolean => {
const activityTime = new Date(activity.timestamp)
const fiveMinutesAgo = new Date(Date.now() - 5 * 60 * 1000)
return activityTime > fiveMinutesAgo
}
const totalActivitiesText = computed(() => {
const count = store.activities.length
if (count === 0) return ''
if (count === 1) return '1 recent update'
return `${count} recent updates`
})
const socialProofMessage = computed(() => {
if (!props.showSocialProof || !store.activities.length) return ''
const recentCompletions = store.activities
.filter(activity => activity.event_type.includes('completed'))
.slice(0, 3)
if (recentCompletions.length === 0) {
return 'Your household is getting organized! 🏠'
}
if (recentCompletions.length === 1) {
return `${recentCompletions[0].user.name || 'Someone'} is staying productive! 💪`
}
return `${recentCompletions.length} completed tasks this week. Keep it up! 🎉`
})
// Event handlers
const handleActivityClick = (activity: Activity) => {
emit('activity-click', activity)
// Auto-navigate based on activity type
if (activity.event_type.includes('chore')) {
router.push('/chores')
} else if (activity.event_type.includes('expense')) {
router.push('/expenses')
} else if (activity.event_type.includes('item')) {
router.push('/lists')
}
}
const handleViewAll = () => {
router.push('/activity')
}
const handleRetry = () => {
if (groupStore.currentGroupId) {
store.fetchActivities(groupStore.currentGroupId)
}
}
const handleCreateSomething = () => {
emit('create-action')
}
// Lifecycle
onMounted(() => { onMounted(() => {
if (groupStore.currentGroupId) { if (groupStore.currentGroupId) {
store.fetchActivities(groupStore.currentGroupId) store.fetchActivities(groupStore.currentGroupId)
@ -51,6 +303,7 @@ onUnmounted(() => {
store.disconnectWebSocket() store.disconnectWebSocket()
}) })
// Intersection observer for infinite scroll
useIntersectionObserver( useIntersectionObserver(
loadMoreSentinel, loadMoreSentinel,
([{ isIntersecting }]) => { ([{ isIntersecting }]) => {
@ -59,4 +312,239 @@ useIntersectionObserver(
} }
}, },
) )
// Track new activities for animations
watch(() => store.activities, (newActivities, oldActivities) => {
if (oldActivities && newActivities.length > oldActivities.length) {
// New activities were added
const newIds = newActivities
.slice(0, newActivities.length - oldActivities.length)
.map(activity => activity.id)
newIds.forEach(id => recentActivityIds.value.add(id))
// Remove from recent set after animation
setTimeout(() => {
newIds.forEach(id => recentActivityIds.value.delete(id))
}, 3000)
}
}, { flush: 'post' })
</script> </script>
<style scoped>
.activity-feed {
@apply bg-white rounded-xl border border-neutral-200 shadow-soft overflow-hidden;
transition: all 300ms ease-page;
}
.feed-header {
@apply flex items-center justify-between p-4 border-b border-neutral-100 bg-neutral-50/50;
}
.header-content {
@apply flex items-center gap-3;
}
.feed-title {
@apply text-lg font-semibold text-neutral-900;
}
.activity-count {
@apply text-sm text-neutral-500 font-medium;
}
.header-actions {
@apply flex items-center gap-2;
}
.view-all-btn {
@apply p-2 rounded-lg text-neutral-400 hover:text-primary-600 hover:bg-primary-50 transition-all duration-micro;
}
.feed-content {
@apply relative;
max-height: v-bind(maxHeight);
overflow-y: auto;
}
/* Loading State */
.feed-loading {
@apply p-8;
}
.loading-content {
@apply flex flex-col items-center gap-3;
}
.loading-spinner {
@apply animate-pulse;
}
.loading-text {
@apply text-sm text-neutral-500 font-medium;
}
/* Error State */
.feed-error {
@apply p-6;
}
.error-content {
@apply flex flex-col items-center gap-3 text-center;
}
.error-icon {
@apply text-2xl text-error-500;
}
.error-message {
@apply text-sm text-error-600 font-medium;
}
.retry-btn {
@apply px-4 py-2 bg-error-50 text-error-700 rounded-lg border border-error-200 hover:bg-error-100 transition-colors duration-micro;
}
/* Activity Groups */
.activity-groups {
@apply divide-y divide-neutral-100;
}
.activity-group {
@apply relative;
animation: fade-in 300ms ease-out;
}
.activity-group.new-group {
animation: scale-in 150ms ease-out;
}
.group-header {
@apply flex items-center justify-between px-4 py-3 bg-neutral-50/30;
}
.group-time {
@apply text-sm font-semibold text-neutral-700;
}
.group-count {
@apply text-xs text-neutral-500 font-medium;
}
.group-activities {
@apply divide-y divide-neutral-50;
}
/* Load More */
.load-more-sentinel {
@apply flex items-center justify-center p-4 transition-opacity duration-micro;
}
.load-more-sentinel:not(.loading) {
@apply opacity-0;
}
.load-more-spinner {
@apply flex items-center gap-2;
}
.load-more-text {
@apply text-sm text-neutral-500;
}
/* Empty State */
.feed-empty {
@apply p-8;
}
.empty-content {
@apply flex flex-col items-center text-center gap-4;
}
.empty-illustration {
@apply w-16 h-16 rounded-full bg-neutral-100 flex items-center justify-center;
}
.empty-icon {
@apply text-2xl text-neutral-400;
}
.empty-title {
@apply text-lg font-semibold text-neutral-700;
}
.empty-description {
@apply text-sm text-neutral-500 max-w-xs leading-relaxed;
}
.empty-actions {
@apply flex gap-3 mt-2;
}
.empty-action-btn {
@apply flex items-center gap-2 px-4 py-2 rounded-lg font-medium transition-all duration-micro;
}
.empty-action-btn.primary {
@apply bg-primary-500 text-white hover:bg-primary-600 shadow-soft;
}
/* Social Proof */
.social-proof {
@apply border-t border-neutral-100 bg-gradient-to-r from-success-50 to-primary-50 p-4;
}
.social-proof-content {
@apply flex items-center gap-3;
}
.social-proof-icon {
@apply text-success-500 text-lg;
}
.social-proof-text {
@apply text-sm text-success-700 font-medium;
}
/* Animations */
@keyframes fade-in {
0% {
opacity: 0;
transform: translateY(10px);
}
100% {
opacity: 1;
transform: translateY(0);
}
}
@keyframes scale-in {
0% {
opacity: 0;
transform: scale(0.95);
}
100% {
opacity: 1;
transform: scale(1);
}
}
/* Scrollbar styling */
.feed-content::-webkit-scrollbar {
width: 4px;
}
.feed-content::-webkit-scrollbar-track {
@apply bg-neutral-100;
}
.feed-content::-webkit-scrollbar-thumb {
@apply bg-neutral-300 rounded-full;
}
.feed-content::-webkit-scrollbar-thumb:hover {
@apply bg-neutral-400;
}
</style>

View File

@ -1,44 +1,630 @@
<template> <template>
<div class="flex items-start space-x-3 py-3"> <div class="activity-item" :class="activityClasses" @click="handleClick" @mouseenter="isHovered = true"
<div class="flex-shrink-0"> @mouseleave="isHovered = false">
<div class="h-8 w-8 rounded-full bg-gray-200 dark:bg-neutral-700 flex items-center justify-center"> <!-- Activity Icon with Status -->
<BaseIcon :name="iconName" class="h-5 w-5 text-gray-500" /> <div class="activity-icon-container">
<div class="activity-icon" :class="iconClasses">
<span class="material-icons icon">{{ materialIcon }}</span>
<!-- Celebration Animation for Completions -->
<div v-if="isCompletion && showCelebration" class="celebration-particles">
<div class="particle" v-for="i in 6" :key="`particle-${i}`"></div>
</div> </div>
</div> </div>
<div class="min-w-0 flex-1">
<p class="text-sm text-gray-800 dark:text-gray-200" v-html="activity.message"></p> <!-- User Avatar -->
<p class="text-xs text-gray-500 dark:text-gray-400"> <div class="user-avatar" v-if="activity.user">
<time :datetime="activity.timestamp">{{ formattedTimestamp }}</time> <div class="avatar-fallback">
</p> {{ userInitials }}
</div> </div>
</div> </div>
</div>
<!-- Activity Content -->
<div class="activity-content">
<!-- Message -->
<div class="activity-message">
<p class="message-text" v-html="enhancedMessage"></p>
<!-- Action Badges -->
<div v-if="actionBadges.length > 0" class="action-badges">
<span v-for="badge in actionBadges" :key="badge.text" class="action-badge" :class="badge.variant">
{{ badge.text }}
</span>
</div>
</div>
<!-- Metadata -->
<div class="activity-metadata">
<div class="metadata-left">
<time class="activity-time" :datetime="activity.timestamp">
{{ formattedTimestamp }}
</time>
<!-- Location/Context -->
<span v-if="contextInfo" class="context-info">
<span class="material-icons context-icon">{{ contextInfo.icon }}</span>
{{ contextInfo.text }}
</span>
</div>
<!-- Quick Actions -->
<div v-if="isHovered && quickActions.length > 0" class="quick-actions">
<button v-for="action in quickActions" :key="action.key" class="quick-action-btn"
:class="action.variant" @click.stop="handleQuickAction(action)" :aria-label="action.label">
<span class="material-icons">{{ action.icon }}</span>
</button>
</div>
</div>
<!-- Details Preview -->
<div v-if="showDetails && activityDetails" class="activity-details">
<div class="details-content">
<div v-for="(value, key) in activityDetails" :key="String(key)" class="detail-item">
<span class="detail-label">{{ formatDetailKey(String(key)) }}:</span>
<span class="detail-value">{{ formatDetailValue(value) }}</span>
</div>
</div>
</div>
</div>
<!-- Status Indicator -->
<div v-if="statusIndicator" class="status-indicator" :class="statusIndicator.variant">
<span class="material-icons status-icon">{{ statusIndicator.icon }}</span>
</div>
<!-- New Activity Pulse -->
<div v-if="isNew" class="new-activity-pulse"></div>
</div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue' import { computed, ref, onMounted } from 'vue'
import { formatDistanceToNow } from 'date-fns' import { formatDistanceToNow } from 'date-fns'
import { type Activity, ActivityEventType } from '@/types/activity' import { type Activity, ActivityEventType } from '@/types/activity'
import BaseIcon from '@/components/BaseIcon.vue'
const props = defineProps<{ // Props
interface Props {
activity: Activity activity: Activity
isNew?: boolean
showDetails?: boolean
compact?: boolean
}
const props = withDefaults(defineProps<Props>(), {
isNew: false,
showDetails: false,
compact: false,
})
// Emits
const emit = defineEmits<{
'click': [activity: Activity]
'quick-action': [action: string, activity: Activity]
}>() }>()
// State
const isHovered = ref(false)
const showCelebration = ref(false)
// Computed Properties
const formattedTimestamp = computed(() => { const formattedTimestamp = computed(() => {
return formatDistanceToNow(new Date(props.activity.timestamp), { addSuffix: true }) return formatDistanceToNow(new Date(props.activity.timestamp), { addSuffix: true })
}) })
const iconMap: Record<ActivityEventType, string> = { const userInitials = computed(() => {
[ActivityEventType.CHORE_COMPLETED]: 'heroicons:check-circle', if (!props.activity.user?.name) return '?'
[ActivityEventType.CHORE_CREATED]: 'heroicons:plus-circle', return props.activity.user.name
[ActivityEventType.EXPENSE_CREATED]: 'heroicons:currency-dollar', .split(' ')
[ActivityEventType.EXPENSE_SETTLED]: 'heroicons:receipt-percent', .map(name => name.charAt(0))
[ActivityEventType.ITEM_ADDED]: 'heroicons:shopping-cart', .join('')
[ActivityEventType.ITEM_COMPLETED]: 'heroicons:check', .substring(0, 2)
[ActivityEventType.USER_JOINED_GROUP]: 'heroicons:user-plus', .toUpperCase()
})
const isCompletion = computed(() => {
return props.activity.event_type.includes('completed')
})
// Icon mapping with Material Icons
const materialIconMap: Record<ActivityEventType, string> = {
[ActivityEventType.CHORE_COMPLETED]: 'task_alt',
[ActivityEventType.CHORE_CREATED]: 'add_task',
[ActivityEventType.EXPENSE_CREATED]: 'payments',
[ActivityEventType.EXPENSE_SETTLED]: 'receipt_long',
[ActivityEventType.ITEM_ADDED]: 'add_shopping_cart',
[ActivityEventType.ITEM_COMPLETED]: 'shopping_cart_checkout',
[ActivityEventType.USER_JOINED_GROUP]: 'person_add',
} }
const iconName = computed(() => { const materialIcon = computed(() => {
return iconMap[props.activity.event_type] || 'heroicons:question-mark-circle' return materialIconMap[props.activity.event_type] || 'info'
})
// Enhanced message with formatting
const enhancedMessage = computed(() => {
let message = props.activity.message
// Bold user names
const userName = props.activity.user?.name
if (userName) {
message = message.replace(userName, `<strong>${userName}</strong>`)
}
// Highlight items in quotes
message = message.replace(/'([^']+)'/g, '<span class="highlighted-item">$1</span>')
return message
})
// Activity styling classes
const activityClasses = computed(() => [
'activity-item-base',
{
'activity-compact': props.compact,
'activity-new': props.isNew,
'activity-completion': isCompletion.value,
'activity-interactive': true,
}
])
const iconClasses = computed(() => {
const baseClasses = ['activity-icon-base']
switch (props.activity.event_type) {
case ActivityEventType.CHORE_COMPLETED:
baseClasses.push('icon-success')
break
case ActivityEventType.EXPENSE_CREATED:
baseClasses.push('icon-warning')
break
case ActivityEventType.EXPENSE_SETTLED:
baseClasses.push('icon-success')
break
case ActivityEventType.ITEM_COMPLETED:
baseClasses.push('icon-success')
break
case ActivityEventType.USER_JOINED_GROUP:
baseClasses.push('icon-primary')
break
default:
baseClasses.push('icon-neutral')
}
return baseClasses
})
// Action badges for different activity types
const actionBadges = computed(() => {
const badges: Array<{ text: string; variant: string }> = []
if (props.activity.event_type === ActivityEventType.CHORE_COMPLETED) {
badges.push({ text: 'Completed', variant: 'badge-success' })
} else if (props.activity.event_type === ActivityEventType.EXPENSE_SETTLED) {
badges.push({ text: 'Settled', variant: 'badge-success' })
} else if (props.activity.event_type === ActivityEventType.EXPENSE_CREATED) {
badges.push({ text: 'New Expense', variant: 'badge-warning' })
}
return badges
})
// Context information based on activity type
const contextInfo = computed(() => {
const details = props.activity.details
if (details?.list_id) {
return { icon: 'list', text: 'Shopping List' }
} else if (details?.chore_name) {
return { icon: 'home', text: 'Household' }
} else if (props.activity.event_type.includes('expense')) {
return { icon: 'account_balance_wallet', text: 'Expenses' }
}
return null
})
// Quick actions based on activity type
const quickActions = computed(() => {
const actions: Array<{ key: string; icon: string; label: string; variant: string }> = []
if (props.activity.event_type.includes('chore')) {
actions.push({
key: 'view-chores',
icon: 'task_alt',
label: 'View chores',
variant: 'action-primary'
})
} else if (props.activity.event_type.includes('expense')) {
actions.push({
key: 'view-expenses',
icon: 'payments',
label: 'View expenses',
variant: 'action-warning'
})
} else if (props.activity.event_type.includes('item')) {
actions.push({
key: 'view-lists',
icon: 'list',
label: 'View lists',
variant: 'action-neutral'
})
}
// Always add a "details" action
actions.push({
key: 'view-details',
icon: 'info',
label: 'View details',
variant: 'action-neutral'
})
return actions
})
// Status indicator for certain activities
const statusIndicator = computed(() => {
if (props.isNew) {
return { icon: 'fiber_new', variant: 'status-new' }
}
if (isCompletion.value) {
return { icon: 'check_circle', variant: 'status-success' }
}
return null
})
// Activity details for preview
const activityDetails = computed(() => {
if (!props.showDetails) return null
const filteredDetails = { ...props.activity.details }
delete filteredDetails.user_id // Remove internal IDs
delete filteredDetails.id
return Object.keys(filteredDetails).length > 0 ? filteredDetails : null
})
// Helper functions
const formatDetailKey = (key: string): string => {
return key.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase())
}
const formatDetailValue = (value: any): string => {
if (typeof value === 'object') {
return JSON.stringify(value)
}
return String(value)
}
// Event handlers
const handleClick = () => {
emit('click', props.activity)
}
const handleQuickAction = (action: { key: string }) => {
emit('quick-action', action.key, props.activity)
}
// Lifecycle
onMounted(() => {
if (isCompletion.value && props.isNew) {
// Show celebration animation for new completions
setTimeout(() => {
showCelebration.value = true
setTimeout(() => {
showCelebration.value = false
}, 2000)
}, 500)
}
}) })
</script> </script>
<style scoped>
.activity-item {
@apply relative flex items-start gap-3 p-4 transition-all duration-micro cursor-pointer;
}
.activity-item-base {
@apply hover:bg-neutral-50 rounded-lg;
}
.activity-item.activity-compact {
@apply p-3;
}
.activity-item.activity-new {
@apply bg-primary-50/50 border-l-2 border-primary-400;
animation: slide-in-left 300ms ease-out;
}
.activity-item.activity-completion {
@apply hover:bg-success-50/50;
}
.activity-item:hover {
@apply shadow-soft;
}
/* Icon Container */
.activity-icon-container {
@apply relative flex-shrink-0;
}
.activity-icon {
@apply w-10 h-10 rounded-full flex items-center justify-center relative overflow-hidden;
transition: all 150ms ease-micro;
}
.activity-icon-base {
@apply border-2;
}
.icon-success {
@apply bg-success-100 border-success-300 text-success-700;
}
.icon-warning {
@apply bg-warning-100 border-warning-300 text-warning-700;
}
.icon-primary {
@apply bg-primary-100 border-primary-300 text-primary-700;
}
.icon-neutral {
@apply bg-neutral-100 border-neutral-300 text-neutral-600;
}
.icon {
@apply text-lg relative z-10;
}
/* User Avatar */
.user-avatar {
@apply absolute -bottom-1 -right-1 w-6 h-6 rounded-full border-2 border-white overflow-hidden;
}
.avatar-image {
@apply w-full h-full object-cover;
}
.avatar-fallback {
@apply w-full h-full bg-neutral-300 text-white text-xs font-semibold flex items-center justify-center;
}
/* Activity Content */
.activity-content {
@apply flex-1 min-w-0;
}
.activity-message {
@apply mb-2;
}
.message-text {
@apply text-sm text-neutral-800 leading-relaxed;
}
.message-text :deep(strong) {
@apply font-semibold text-neutral-900;
}
.message-text :deep(.highlighted-item) {
@apply font-medium text-primary-700 bg-primary-50 px-1 rounded;
}
/* Action Badges */
.action-badges {
@apply flex flex-wrap gap-2 mt-2;
}
.action-badge {
@apply inline-flex items-center px-2 py-1 text-xs font-medium rounded-full;
}
.badge-success {
@apply bg-success-100 text-success-800;
}
.badge-warning {
@apply bg-warning-100 text-warning-800;
}
.badge-primary {
@apply bg-primary-100 text-primary-800;
}
/* Metadata */
.activity-metadata {
@apply flex items-center justify-between;
}
.metadata-left {
@apply flex items-center gap-3;
}
.activity-time {
@apply text-xs text-neutral-500 font-medium;
}
.context-info {
@apply flex items-center gap-1 text-xs text-neutral-500;
}
.context-icon {
@apply text-sm;
}
/* Quick Actions */
.quick-actions {
@apply flex items-center gap-1;
animation: fade-in 150ms ease-out;
}
.quick-action-btn {
@apply p-1.5 rounded-lg transition-all duration-micro;
}
.action-primary {
@apply text-primary-600 hover:bg-primary-100;
}
.action-warning {
@apply text-warning-600 hover:bg-warning-100;
}
.action-neutral {
@apply text-neutral-500 hover:bg-neutral-100;
}
.quick-action-btn .material-icons {
@apply text-sm;
}
/* Activity Details */
.activity-details {
@apply mt-3 p-3 bg-neutral-50 rounded-lg border border-neutral-200;
}
.details-content {
@apply space-y-1;
}
.detail-item {
@apply flex gap-2 text-xs;
}
.detail-label {
@apply font-medium text-neutral-600;
}
.detail-value {
@apply text-neutral-800;
}
/* Status Indicator */
.status-indicator {
@apply absolute top-2 right-2 w-6 h-6 rounded-full flex items-center justify-center;
}
.status-new {
@apply bg-primary-500 text-white;
}
.status-success {
@apply bg-success-500 text-white;
}
.status-icon {
@apply text-sm;
}
/* New Activity Pulse */
.new-activity-pulse {
@apply absolute -top-1 -left-1 w-3 h-3 bg-primary-500 rounded-full;
animation: pulse-scale 2s infinite;
}
/* Celebration Animation */
.celebration-particles {
@apply absolute inset-0 pointer-events-none;
}
.particle {
@apply absolute w-1 h-1 bg-success-400 rounded-full;
animation: celebration-burst 1.5s ease-out forwards;
}
.particle:nth-child(1) {
animation-delay: 0ms;
transform: rotate(0deg);
}
.particle:nth-child(2) {
animation-delay: 100ms;
transform: rotate(60deg);
}
.particle:nth-child(3) {
animation-delay: 200ms;
transform: rotate(120deg);
}
.particle:nth-child(4) {
animation-delay: 300ms;
transform: rotate(180deg);
}
.particle:nth-child(5) {
animation-delay: 400ms;
transform: rotate(240deg);
}
.particle:nth-child(6) {
animation-delay: 500ms;
transform: rotate(300deg);
}
/* Animations */
@keyframes slide-in-left {
0% {
opacity: 0;
transform: translateX(-20px);
}
100% {
opacity: 1;
transform: translateX(0);
}
}
@keyframes fade-in {
0% {
opacity: 0;
transform: scale(0.95);
}
100% {
opacity: 1;
transform: scale(1);
}
}
@keyframes pulse-scale {
0%,
100% {
transform: scale(1);
opacity: 1;
}
50% {
transform: scale(1.5);
opacity: 0.5;
}
}
@keyframes celebration-burst {
0% {
transform: translateX(0) translateY(0) scale(0);
opacity: 1;
}
100% {
transform: translateX(20px) translateY(-20px) scale(1);
opacity: 0;
}
}
/* Hover effects */
.activity-item:hover .activity-icon {
transform: scale(1.05);
}
.activity-item.activity-completion:hover .activity-icon.icon-success {
@apply shadow-success-300;
box-shadow: 0 0 0 4px rgb(16 185 129 / 20%);
}
</style>

View File

@ -1,54 +1,284 @@
<template> <template>
<div class="rounded-lg bg-white dark:bg-dark shadow p-4"> <Card class="personal-status-card">
<div v-if="isLoading" class="text-center text-gray-500"> <!-- Header -->
Loading your status... <div class="flex items-center justify-between mb-4">
</div> <Heading size="lg" class="text-neutral-900">
<div v-else class="flex items-center space-x-4"> {{ greeting }}
<div class="flex-shrink-0"> </Heading>
<div class="h-12 w-12 rounded-full flex items-center justify-center" :class="iconBgColor"> <div class="flex items-center gap-2">
<BaseIcon :name="iconName" class="h-6 w-6 text-white" /> <div v-if="personalStatus.streakCount > 0" class="streak-indicator">
</div> <span class="material-icons text-warning-500 text-sm">local_fire_department</span>
</div> <span class="text-xs font-medium text-warning-600">{{ personalStatus.streakCount }}</span>
<div class="flex-1 min-w-0">
<p class="text-lg font-semibold text-gray-900 dark:text-white truncate">{{ nextAction.title }}</p>
<p class="text-sm text-gray-500 dark:text-gray-400">{{ nextAction.subtitle }}</p>
</div>
<div class="flex-shrink-0">
<router-link :to="nextAction.path">
<Button variant="solid">{{ nextAction.cta }}</Button>
</router-link>
</div> </div>
</div> </div>
</div> </div>
<!-- Main Status Content -->
<div class="status-content">
<!-- Priority Action -->
<div v-if="priorityAction" class="priority-action-section mb-6">
<div class="flex items-start gap-3">
<div class="priority-icon">
<span class="material-icons" :class="getPriorityIconClass(priorityAction.priority)">
{{ getPriorityIcon(priorityAction.type) }}
</span>
</div>
<div class="flex-1">
<h3 class="text-sm font-medium text-neutral-900 mb-1">
{{ getPriorityActionTitle(priorityAction) }}
</h3>
<p class="text-xs text-neutral-600 mb-3">
{{ getPriorityActionDescription(priorityAction) }}
</p>
<Button :variant="getPriorityActionVariant(priorityAction.priority)"
:color="getPriorityActionColor(priorityAction.priority)" size="sm"
@click="handlePriorityAction(priorityAction)" class="w-full">
{{ getPriorityActionButtonText(priorityAction) }}
</Button>
</div>
</div>
</div>
<!-- Empty State -->
<div v-else class="empty-state text-center py-8">
<div class="mb-4">
<span class="material-icons text-success-400 text-4xl">check_circle</span>
</div>
<h3 class="text-sm font-medium text-neutral-900 mb-2">
{{ getEmptyStateTitle() }}
</h3>
<p class="text-xs text-neutral-600 mb-4">
{{ getEmptyStateMessage() }}
</p>
<Button variant="soft" color="success" size="sm" @click="handleCreateSomething">
{{ getEmptyStateAction() }}
</Button>
</div>
<!-- Quick Stats -->
<div v-if="priorityAction" class="quick-stats grid grid-cols-3 gap-4 mt-6 pt-6 border-t border-neutral-200">
<div class="stat-item text-center">
<div class="text-lg font-semibold text-neutral-900">{{ personalStatus.completedToday }}</div>
<div class="text-xs text-neutral-500">Completed</div>
</div>
<div class="stat-item text-center">
<div class="text-lg font-semibold" :class="getBalanceColor(personalStatus.netBalance)">
{{ formatCurrency(personalStatus.netBalance) }}
</div>
<div class="text-xs text-neutral-500">Balance</div>
</div>
<div class="stat-item text-center">
<div class="text-lg font-semibold text-neutral-900">{{ personalStatus.pointsThisWeek }}</div>
<div class="text-xs text-neutral-500">Points</div>
</div>
</div>
</div>
</Card>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue'; import { computed } from 'vue'
import { usePersonalStatus } from '@/composables/usePersonalStatus'; import { usePersonalStatus } from '@/composables/usePersonalStatus'
import BaseIcon from '@/components/BaseIcon.vue'; import Card from '@/components/ui/Card.vue'
import Button from '@/components/ui/Button.vue'; import Button from '@/components/ui/Button.vue'
import Heading from '@/components/ui/Heading.vue'
const { nextAction, isLoading } = usePersonalStatus(); interface PriorityAction {
id: string
type: 'chore' | 'expense' | 'debt' | 'overdue'
priority: 'urgent' | 'high' | 'medium' | 'low'
title: string
description?: string
dueDate?: Date
amount?: number
actionUrl: string
}
const iconName = computed(() => { const { personalStatus, priorityAction } = usePersonalStatus()
switch (nextAction.value.type) {
const greeting = computed(() => {
const hour = new Date().getHours()
if (hour < 12) return 'Good morning!'
if (hour < 17) return 'Good afternoon!'
return 'Good evening!'
})
const getPriorityIcon = (type: string) => {
const icons = {
chore: 'task_alt',
expense: 'receipt',
debt: 'payments',
overdue: 'schedule'
}
return icons[type as keyof typeof icons] || 'info'
}
const getPriorityIconClass = (priority: string) => {
const classes = {
urgent: 'text-error-500 bg-error-100 p-2 rounded-full',
high: 'text-warning-500 bg-warning-100 p-2 rounded-full',
medium: 'text-primary-500 bg-primary-100 p-2 rounded-full',
low: 'text-neutral-500 bg-neutral-100 p-2 rounded-full'
}
return classes[priority as keyof typeof classes] || classes.low
}
const getPriorityActionTitle = (action: PriorityAction) => {
switch (action.type) {
case 'chore': case 'chore':
return 'heroicons:bell-alert'; return `Complete: ${action.title}`
case 'expense': case 'expense':
return 'heroicons:credit-card'; return `Add expense: ${action.title}`
case 'debt':
return `Settle up: ${action.title}`
case 'overdue':
return `Overdue: ${action.title}`
default: default:
return 'heroicons:check-circle'; return action.title
}
} }
});
const iconBgColor = computed(() => { const getPriorityActionDescription = (action: PriorityAction) => {
switch (nextAction.value.priority) { if (action.description) return action.description
case 1:
return 'bg-red-500'; switch (action.type) {
case 2: case 'chore':
return 'bg-yellow-500'; return action.dueDate ? `Due ${formatRelativeTime(action.dueDate)}` : 'Ready to complete'
case 'expense':
return 'Quick expense entry'
case 'debt':
return action.amount ? `Amount: ${formatCurrency(action.amount)}` : 'Resolve outstanding balance'
case 'overdue':
return `This was due ${formatRelativeTime(action.dueDate!)}`
default: default:
return 'bg-green-500'; return ''
} }
}); }
const getPriorityActionVariant = (priority: string) => {
return priority === 'urgent' ? 'solid' : 'soft'
}
const getPriorityActionColor = (priority: string): 'primary' | 'secondary' | 'success' | 'warning' | 'error' | 'neutral' => {
const colors: Record<string, 'primary' | 'secondary' | 'success' | 'warning' | 'error' | 'neutral'> = {
urgent: 'error',
high: 'warning',
medium: 'primary',
low: 'neutral'
}
return colors[priority] || 'primary'
}
const getPriorityActionButtonText = (action: PriorityAction) => {
switch (action.type) {
case 'chore':
return 'Mark Complete'
case 'expense':
return 'Add Expense'
case 'debt':
return 'Settle Up'
case 'overdue':
return 'View Details'
default:
return 'Take Action'
}
}
const getEmptyStateTitle = () => {
const hour = new Date().getHours()
if (hour < 12) return 'You\'re all caught up!'
if (hour < 17) return 'Looking good!'
return 'All done for today!'
}
const getEmptyStateMessage = () => {
const messages = [
'No urgent tasks right now. Great job staying on top of things!',
'Everything is up to date. Maybe add something to your list?',
'Your household is running smoothly. Time to relax!',
'All tasks complete! Consider planning something fun.'
]
return messages[Math.floor(Math.random() * messages.length)]
}
const getEmptyStateAction = () => {
const actions = [
'Add New Task',
'Create Shopping List',
'Plan Chore',
'Quick Add'
]
return actions[Math.floor(Math.random() * actions.length)]
}
const getBalanceColor = (balance: number) => {
if (balance > 0) return 'text-success-600'
if (balance < 0) return 'text-error-600'
return 'text-neutral-600'
}
const formatCurrency = (amount: number) => {
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD'
}).format(amount)
}
const formatRelativeTime = (date: Date) => {
const now = new Date()
const diff = now.getTime() - date.getTime()
const days = Math.floor(diff / (1000 * 60 * 60 * 24))
if (days === 0) return 'today'
if (days === 1) return 'yesterday'
if (days > 0) return `${days} days ago`
if (days === -1) return 'tomorrow'
return `in ${Math.abs(days)} days`
}
const handlePriorityAction = (action: PriorityAction) => {
// This will be handled by the parent component or router
// For now, we emit an event
emit('priority-action', action)
}
const handleCreateSomething = () => {
emit('create-action')
}
const emit = defineEmits<{
'priority-action': [action: PriorityAction]
'create-action': []
}>()
</script> </script>
<style scoped>
.personal-status-card {
@apply bg-white border border-neutral-200 shadow-soft;
animation: fade-in 300ms ease-out;
}
.priority-action-section {
@apply p-4 bg-gradient-to-r from-blue-50 to-indigo-50 rounded-lg border border-blue-100;
}
.priority-icon {
@apply flex-shrink-0;
}
.empty-state {
@apply px-4;
}
.stat-item {
transition: transform 150ms ease-micro;
}
.stat-item:hover {
transform: scale(1.05);
}
.streak-indicator {
@apply flex items-center gap-1 px-2 py-1 bg-warning-50 rounded-full border border-warning-200;
}
</style>

View File

@ -0,0 +1,513 @@
<template>
<Card variant="soft" color="primary" padding="lg" class="quick-win-suggestion">
<!-- Loading State -->
<div v-if="isLoading" class="suggestion-loading">
<Spinner size="sm" />
<span class="loading-text">Finding your next quick win...</span>
</div>
<!-- Suggestion Content -->
<div v-else-if="currentSuggestion" class="suggestion-content">
<!-- Suggestion Header -->
<div class="suggestion-header">
<div class="suggestion-icon">
<BaseIcon :name="getSuggestionIconName(currentSuggestion.icon)" :class="iconVariantClasses" />
</div>
<div class="suggestion-meta">
<span class="suggestion-type">{{ currentSuggestion.type }}</span>
<span class="suggestion-points">+{{ currentSuggestion.points }} points</span>
</div>
</div>
<!-- Suggestion Body -->
<div class="suggestion-body">
<h3 class="suggestion-title">{{ currentSuggestion.title }}</h3>
<p class="suggestion-description">{{ currentSuggestion.description }}</p>
<!-- Context Information -->
<div v-if="currentSuggestion.context" class="suggestion-context">
<span class="context-indicator">
<BaseIcon name="heroicons:information-circle-20-solid" class="context-icon" />
{{ currentSuggestion.context }}
</span>
</div>
</div>
<!-- Suggestion Actions -->
<div class="suggestion-actions">
<Button :variant="currentSuggestion.priority === 'high' ? 'solid' : 'outline'" :color="actionColor"
size="sm" fullWidth @click="handleTakeAction" :loading="isActioning" class="primary-action">
<template #icon-left>
<BaseIcon :name="getSuggestionIconName(currentSuggestion.actionIcon)" />
</template>
{{ currentSuggestion.actionText }}
</Button>
<Button variant="ghost" color="neutral" size="sm" @click="handleDismiss" class="dismiss-btn">
<template #icon-left>
<BaseIcon name="heroicons:x-mark-20-solid" />
</template>
Dismiss
</Button>
</div>
</div>
<!-- Empty State -->
<div v-else class="suggestion-empty">
<div class="empty-icon">
<BaseIcon name="heroicons:trophy-20-solid" class="trophy-icon" />
</div>
<h3 class="empty-title">All caught up!</h3>
<p class="empty-description">No immediate suggestions right now. Check back later for new opportunities.</p>
</div>
</Card>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
import { useRouter } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
import { useChoreStore } from '@/stores/choreStore'
import { useListsStore } from '@/stores/listsStore'
import { useNotificationStore } from '@/stores/notifications'
import { Card, Button, Spinner } from '@/components/ui'
import BaseIcon from '@/components/BaseIcon.vue'
interface Suggestion {
id: string
type: 'Chore' | 'Shopping' | 'Expense' | 'Organization'
priority: 'high' | 'medium' | 'low'
title: string
description: string
context?: string
icon: string
actionIcon: string
actionText: string
points: number
action: () => void | Promise<void>
target?: any // The target item/chore/etc for the action
}
const router = useRouter()
const authStore = useAuthStore()
const choreStore = useChoreStore()
const listsStore = useListsStore()
const notificationStore = useNotificationStore()
const isLoading = ref(true)
const isActioning = ref(false)
const currentSuggestion = ref<Suggestion | null>(null)
const suggestionHistory = ref<Set<string>>(new Set())
const refreshInterval = ref<number | null>(null)
// Enhanced icon mapping for better visual consistency
const getSuggestionIconName = (materialIcon: string): string => {
const iconMap: Record<string, string> = {
'warning': 'heroicons:exclamation-triangle-20-solid',
'today': 'heroicons:calendar-20-solid',
'star': 'heroicons:star-20-solid',
'shopping_cart': 'heroicons:shopping-cart-20-solid',
'schedule': 'heroicons:clock-20-solid',
'check_circle': 'heroicons:check-circle-20-solid',
'play_arrow': 'heroicons:play-20-solid',
'volunteer_activism': 'heroicons:hand-raised-20-solid',
'store': 'heroicons:building-storefront-20-solid',
'calendar_view_day': 'heroicons:calendar-days-20-solid',
'emoji_events': 'heroicons:trophy-20-solid',
'lightbulb': 'heroicons:light-bulb-20-solid',
'target': 'heroicons:cursor-arrow-rays-20-solid',
'flash_on': 'heroicons:bolt-20-solid',
'trending_up': 'heroicons:arrow-trending-up-20-solid'
}
return iconMap[materialIcon] || 'heroicons:sparkles-20-solid'
}
// Enhanced color scheme based on priority and type
const iconVariantClasses = computed(() => {
if (!currentSuggestion.value) return ''
const { priority, type } = currentSuggestion.value
if (priority === 'high') return 'text-error-600 dark:text-error-400'
if (type === 'Chore') return 'text-primary-600 dark:text-primary-400'
if (type === 'Shopping') return 'text-success-600 dark:text-success-400'
if (type === 'Expense') return 'text-warning-600 dark:text-warning-400'
return 'text-text-secondary'
})
const actionColor = computed(() => {
if (!currentSuggestion.value) return 'primary'
const { priority } = currentSuggestion.value
if (priority === 'high') return 'error'
return 'primary'
})
// Generate intelligent suggestions based on user data and patterns
const generateSuggestions = (): Suggestion[] => {
const suggestions: Suggestion[] = []
const now = new Date()
const user = authStore.user
if (!user) return suggestions
// 1. OVERDUE CHORES (Highest Priority)
const allChores = choreStore.allChores
const overdueChores = allChores.filter((chore: any) => {
if (!chore.assignments || chore.assignments.length === 0) return false
const latestAssignment = chore.assignments[chore.assignments.length - 1]
if (latestAssignment.is_complete) return false
const dueDate = new Date(latestAssignment.due_date)
return dueDate < now
})
if (overdueChores.length > 0) {
const urgentChore = overdueChores[0]
const assignment = urgentChore.assignments[urgentChore.assignments.length - 1]
const daysOverdue = Math.ceil((now.getTime() - new Date(assignment.due_date).getTime()) / (1000 * 60 * 60 * 24))
suggestions.push({
id: `overdue-chore-${urgentChore.id}`,
type: 'Chore',
priority: 'high',
title: `Complete "${urgentChore.name}"`,
description: 'This task is overdue and may be blocking others.',
context: `Overdue by ${daysOverdue} days`,
icon: 'warning',
actionIcon: 'check_circle',
actionText: 'Complete Now',
points: 20, // High priority bonus
action: () => handleCompleteChore(urgentChore, assignment),
target: urgentChore
})
}
// 2. DUE TODAY CHORES
const dueTodayChores = allChores.filter((chore: any) => {
if (!chore.assignments || chore.assignments.length === 0) return false
const latestAssignment = chore.assignments[chore.assignments.length - 1]
if (latestAssignment.is_complete) return false
const dueDate = new Date(latestAssignment.due_date)
return dueDate.toDateString() === now.toDateString()
})
if (dueTodayChores.length > 0) {
const todayChore = dueTodayChores[0]
const assignment = todayChore.assignments[todayChore.assignments.length - 1]
suggestions.push({
id: `due-today-${todayChore.id}`,
type: 'Chore',
priority: 'medium',
title: `Tackle "${todayChore.name}"`,
description: 'Due today - perfect time to get it done!',
icon: 'today',
actionIcon: 'play_arrow',
actionText: 'Start Task',
points: 10,
action: () => handleCompleteChore(todayChore, assignment),
target: todayChore
})
}
// 3. AVAILABLE HIGH-POINT CHORES
const availableChores = allChores.filter((chore: any) => {
if (!chore.assignments || chore.assignments.length === 0) return true // Can be claimed
const latestAssignment = chore.assignments[chore.assignments.length - 1]
return !latestAssignment.is_complete
})
if (availableChores.length > 0) {
const highValueChore = availableChores[0]
suggestions.push({
id: `high-value-${highValueChore.id}`,
type: 'Chore',
priority: 'medium',
title: `Earn big with "${highValueChore.name}"`,
description: 'High-value task available for quick points!',
icon: 'star',
actionIcon: 'target',
actionText: 'Claim Task',
points: 15,
action: () => { router.push('/chores') },
target: highValueChore
})
}
// 4. SHOPPING OPTIMIZATION (simplified for current list)
const currentList = listsStore.currentList
if (currentList && currentList.items) {
const pendingItems = currentList.items.filter((item: any) => !item.is_complete && !item.claimed_by_user_id)
if (pendingItems.length >= 3) {
suggestions.push({
id: `shopping-${currentList.id}`,
type: 'Shopping',
priority: 'medium',
title: 'Complete shopping trip',
description: `${pendingItems.length} items waiting on "${currentList.name}"`,
icon: 'shopping_cart',
actionIcon: 'check_circle',
actionText: 'Shop Now',
points: Math.min(pendingItems.length * 2, 20),
action: () => { router.push(`/lists/${currentList.id}`) },
target: currentList
})
}
}
// 5. QUICK WINS BASED ON TIME
const hour = now.getHours()
if (hour >= 7 && hour <= 10) {
// Morning suggestions
suggestions.push({
id: 'morning-routine',
type: 'Organization',
priority: 'low',
title: 'Start strong today',
description: 'Morning energy is perfect for quick organization tasks',
icon: 'lightbulb',
actionIcon: 'flash_on',
actionText: 'Get Organized',
points: 5,
action: () => { router.push('/chores?filter=quick') }
})
} else if (hour >= 18 && hour <= 21) {
// Evening wind-down
suggestions.push({
id: 'evening-prep',
type: 'Organization',
priority: 'low',
title: 'Prep for tomorrow',
description: 'Evening planning makes mornings smoother',
icon: 'schedule',
actionIcon: 'trending_up',
actionText: 'Plan Ahead',
points: 5,
action: () => { router.push('/dashboard') }
})
}
return suggestions
}
// Actions
const handleCompleteChore = async (chore: any, assignment: any) => {
try {
isActioning.value = true
await choreStore.toggleCompletion(chore, assignment)
notificationStore.addNotification({
type: 'success',
message: `Great job finishing "${chore.name}"`
})
refreshSuggestions()
} catch (error) {
notificationStore.addNotification({
type: 'error',
message: 'Failed to complete task'
})
} finally {
isActioning.value = false
}
}
const handleTakeAction = async () => {
if (!currentSuggestion.value) return
try {
await currentSuggestion.value.action()
suggestionHistory.value.add(currentSuggestion.value.id)
} catch (error) {
console.error('Action failed:', error)
}
}
const handleDismiss = () => {
if (!currentSuggestion.value) return
suggestionHistory.value.add(currentSuggestion.value.id)
refreshSuggestions()
}
const refreshSuggestions = () => {
const allSuggestions = generateSuggestions()
const availableSuggestions = allSuggestions.filter(s =>
!suggestionHistory.value.has(s.id)
)
// Prioritize by urgency then points
availableSuggestions.sort((a, b) => {
const priorityWeight = { high: 3, medium: 2, low: 1 }
const aPriority = priorityWeight[a.priority]
const bPriority = priorityWeight[b.priority]
if (aPriority !== bPriority) return bPriority - aPriority
return b.points - a.points
})
currentSuggestion.value = availableSuggestions[0] || null
}
const setupRefreshInterval = () => {
// Refresh suggestions every 5 minutes
refreshInterval.value = window.setInterval(refreshSuggestions, 5 * 60 * 1000)
}
// Lifecycle
onMounted(async () => {
isLoading.value = true
// Load initial data
await choreStore.fetchPersonal()
refreshSuggestions()
setupRefreshInterval()
isLoading.value = false
})
onUnmounted(() => {
if (refreshInterval.value) {
clearInterval(refreshInterval.value)
}
})
// Watch for store updates to refresh suggestions
watch([
() => choreStore.allChores,
() => listsStore.currentList
], refreshSuggestions)
</script>
<style scoped>
.quick-win-suggestion {
@apply transition-all duration-micro ease-micro;
@apply hover:shadow-medium hover:scale-[1.01];
@apply transform-gpu;
}
/* Loading State */
.suggestion-loading {
@apply flex items-center gap-3;
@apply text-text-secondary;
}
.loading-text {
@apply text-sm font-medium;
}
/* Suggestion Content */
.suggestion-content {
@apply space-y-4;
}
.suggestion-header {
@apply flex items-start justify-between;
}
.suggestion-icon {
@apply flex-shrink-0 w-10 h-10;
@apply flex items-center justify-center;
@apply rounded-lg bg-surface-elevated;
@apply transition-all duration-micro ease-micro;
}
.suggestion-icon svg {
@apply w-5 h-5;
}
.suggestion-meta {
@apply flex flex-col gap-1 text-right;
}
.suggestion-type {
@apply text-xs font-medium text-text-secondary;
@apply uppercase tracking-wide;
}
.suggestion-points {
@apply text-sm font-semibold text-primary-600 dark:text-primary-400;
}
/* Suggestion Body */
.suggestion-body {
@apply space-y-2;
}
.suggestion-title {
@apply text-lg font-semibold text-text-primary;
@apply leading-tight;
}
.suggestion-description {
@apply text-sm text-text-secondary;
@apply leading-relaxed;
}
.suggestion-context {
@apply mt-3;
}
.context-indicator {
@apply inline-flex items-center gap-2;
@apply px-3 py-1.5;
@apply bg-warning-50 dark:bg-warning-950/50;
@apply border border-warning-200 dark:border-warning-800;
@apply rounded-md;
@apply text-xs font-medium text-warning-700 dark:text-warning-300;
}
.context-icon {
@apply w-3.5 h-3.5 flex-shrink-0;
}
/* Suggestion Actions */
.suggestion-actions {
@apply flex gap-2;
}
.primary-action {
@apply flex-1;
}
.dismiss-btn {
@apply flex-shrink-0;
}
/* Empty State */
.suggestion-empty {
@apply text-center py-4;
}
.empty-icon {
@apply flex justify-center mb-3;
}
.trophy-icon {
@apply w-8 h-8 text-success-500;
}
.empty-title {
@apply text-lg font-semibold text-text-primary mb-2;
}
.empty-description {
@apply text-sm text-text-secondary;
@apply max-w-xs mx-auto;
}
/* Responsive adjustments */
@media (max-width: 640px) {
.suggestion-header {
@apply flex-col gap-3;
}
.suggestion-meta {
@apply flex-row justify-between text-left;
}
.suggestion-actions {
@apply flex-col;
}
}
</style>

View File

@ -1,61 +1,428 @@
<template> <template>
<div class="fixed bottom-4 right-4"> <div class="universal-fab-container">
<Menu as="div" class="relative inline-block text-left"> <!-- Main FAB Button -->
<div> <Transition name="fab-main" appear>
<MenuButton as="template"> <button ref="fabButton" class="fab-main" :class="{ 'is-open': isOpen }" @click="toggleFAB"
<Button variant="solid" class="rounded-full w-14 h-14 flex items-center justify-center shadow-lg"> @touchstart="handleTouchStart" :aria-expanded="isOpen" aria-label="Quick actions menu">
<BaseIcon name="heroicons:plus" class="h-7 w-7 text-white" /> <Transition name="fab-icon" mode="out-in">
</Button> <span v-if="isOpen" key="close" class="material-icons">close</span>
</MenuButton> <span v-else key="add" class="material-icons">add</span>
</div> </Transition>
</button>
<transition enter-active-class="transition duration-100 ease-out" </Transition>
enter-from-class="transform scale-95 opacity-0" enter-to-class="transform scale-100 opacity-100"
leave-active-class="transition duration-75 ease-in" leave-from-class="transform scale-100 opacity-100" <!-- Action Buttons -->
leave-to-class="transform scale-95 opacity-0"> <Transition name="fab-actions">
<MenuItems <div v-if="isOpen" class="fab-actions-container">
class="absolute bottom-16 right-0 w-56 origin-bottom-right rounded-md bg-white dark:bg-dark shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none"> <button v-for="(action, index) in sortedActions" :key="action.id" class="fab-action"
<div class="px-1 py-1"> :style="getActionPosition(index)" @click="handleActionClick(action)" :aria-label="action.label">
<MenuItem v-for="item in menuItems" :key="item.label" v-slot="{ active }"> <span class="material-icons">{{ action.icon }}</span>
<button @click="item.action" :class="[ <span class="fab-action-label">{{ action.label }}</span>
active ? 'bg-primary-500 text-white' : 'text-gray-900 dark:text-gray-100',
'group flex w-full items-center rounded-md px-2 py-2 text-sm',
]">
<BaseIcon :name="item.icon"
:class="[active ? 'text-white' : 'text-primary-500', 'mr-2 h-5 w-5']" />
{{ item.label }}
</button> </button>
</MenuItem>
</div> </div>
</MenuItems> </Transition>
</transition>
</Menu> <!-- Backdrop -->
<Transition name="fab-backdrop">
<div v-if="isOpen" class="fab-backdrop" @click="closeFAB" @touchstart="closeFAB" />
</Transition>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { useRouter } from 'vue-router'; import { ref, computed, onMounted, onUnmounted } from 'vue'
import { Menu, MenuButton, MenuItems, MenuItem } from '@headlessui/vue' import { useRouter } from 'vue-router'
import BaseIcon from '@/components/BaseIcon.vue'; import { useNotificationStore } from '@/stores/notifications'
import Button from '@/components/ui/Button.vue'; import { onClickOutside } from '@vueuse/core'
const router = useRouter(); interface FABAction {
id: string
label: string
icon: string
action: () => void
usageCount: number
priority: number // 1 = highest priority
contextual?: boolean // Shows only in certain contexts
}
const menuItems = [ const router = useRouter()
const notificationStore = useNotificationStore()
const isOpen = ref(false)
const fabButton = ref<HTMLElement | null>(null)
const touchStartTime = ref(0)
// Usage tracking (in a real app, this would be persisted)
const usageStats = ref<Record<string, number>>({
'add-expense': 150, // 80% of usage
'complete-chore': 28, // 15% of usage
'add-to-list': 9, // 5% of usage
'create-list': 5,
'invite-member': 3,
'quick-scan': 2,
})
// Define all possible actions
const allActions = computed<FABAction[]>(() => [
{ {
id: 'add-expense',
label: 'Add Expense', label: 'Add Expense',
icon: 'heroicons:currency-dollar', icon: 'receipt_long',
action: () => router.push('/expenses/new'), priority: 1,
usageCount: usageStats.value['add-expense'] || 0,
action: () => {
incrementUsage('add-expense')
router.push('/expenses/new')
closeFAB()
}
}, },
{ {
id: 'complete-chore',
label: 'Complete Chore', label: 'Complete Chore',
icon: 'heroicons:check-circle', icon: 'task_alt',
action: () => router.push('/chores'), priority: 2,
usageCount: usageStats.value['complete-chore'] || 0,
action: () => {
incrementUsage('complete-chore')
router.push('/chores')
closeFAB()
}
}, },
{ {
id: 'add-to-list',
label: 'Add to List', label: 'Add to List',
icon: 'heroicons:shopping-cart', icon: 'add_shopping_cart',
action: () => router.push('/lists'), priority: 3,
usageCount: usageStats.value['add-to-list'] || 0,
action: () => {
incrementUsage('add-to-list')
router.push('/lists')
closeFAB()
}
}, },
] {
id: 'quick-scan',
label: 'Scan Receipt',
icon: 'qr_code_scanner',
priority: 4,
usageCount: usageStats.value['quick-scan'] || 0,
action: () => {
incrementUsage('quick-scan')
// Emit event for receipt scanning
emit('scan-receipt')
closeFAB()
}
},
{
id: 'create-list',
label: 'New List',
icon: 'playlist_add',
priority: 5,
usageCount: usageStats.value['create-list'] || 0,
action: () => {
incrementUsage('create-list')
emit('create-list')
closeFAB()
}
},
{
id: 'invite-member',
label: 'Invite Member',
icon: 'person_add',
priority: 6,
usageCount: usageStats.value['invite-member'] || 0,
action: () => {
incrementUsage('invite-member')
emit('invite-member')
closeFAB()
}
}
])
// Sort actions by usage count and priority
const sortedActions = computed(() => {
return allActions.value
.slice() // Create a copy
.sort((a, b) => {
// Primary sort: usage count (descending)
if (b.usageCount !== a.usageCount) {
return b.usageCount - a.usageCount
}
// Secondary sort: priority (ascending, lower number = higher priority)
return a.priority - b.priority
})
.slice(0, 4) // Show max 4 actions
})
const toggleFAB = () => {
isOpen.value = !isOpen.value
if (isOpen.value) {
// Add haptic feedback if available
if ('vibrate' in navigator) {
navigator.vibrate(50)
}
}
}
const closeFAB = () => {
isOpen.value = false
}
const handleTouchStart = () => {
touchStartTime.value = Date.now()
}
const handleActionClick = (action: FABAction) => {
// Add haptic feedback
if ('vibrate' in navigator) {
navigator.vibrate(30)
}
action.action()
notificationStore.addNotification({
type: 'success',
message: `${action.label} opened`,
})
}
const incrementUsage = (actionId: string) => {
usageStats.value[actionId] = (usageStats.value[actionId] || 0) + 1
// In a real app, persist this to localStorage or API
localStorage.setItem('fab-usage-stats', JSON.stringify(usageStats.value))
}
// Calculate action position for radial layout
const getActionPosition = (index: number) => {
const totalActions = sortedActions.value.length
const angleStep = (Math.PI * 0.6) / (totalActions - 1) // 108 degree arc
const startAngle = -Math.PI * 0.3 // Start at -54 degrees
const angle = startAngle + (angleStep * index)
const radius = 80 // Distance from center
const x = Math.cos(angle) * radius
const y = Math.sin(angle) * radius
return {
transform: `translate(${x}px, ${y}px)`,
transitionDelay: `${index * 50}ms`
}
}
// Handle clicks outside FAB
onClickOutside(fabButton, () => {
if (isOpen.value) {
closeFAB()
}
})
// Load usage stats on mount
onMounted(() => {
const savedStats = localStorage.getItem('fab-usage-stats')
if (savedStats) {
try {
usageStats.value = { ...usageStats.value, ...JSON.parse(savedStats) }
} catch (e) {
console.warn('Failed to load FAB usage stats:', e)
}
}
})
// Handle escape key
const handleEscapeKey = (event: KeyboardEvent) => {
if (event.key === 'Escape' && isOpen.value) {
closeFAB()
}
}
onMounted(() => {
document.addEventListener('keydown', handleEscapeKey)
})
onUnmounted(() => {
document.removeEventListener('keydown', handleEscapeKey)
})
const emit = defineEmits<{
'scan-receipt': []
'create-list': []
'invite-member': []
}>()
</script> </script>
<style scoped>
.universal-fab-container {
position: fixed;
bottom: 24px;
right: 24px;
z-index: 1000;
}
.fab-main {
@apply w-14 h-14 rounded-full shadow-floating;
@apply bg-primary-500 text-white;
@apply flex items-center justify-center;
transition: all 150ms cubic-bezier(0.4, 0, 0.2, 1);
@apply focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500/50 focus-visible:ring-offset-2;
@apply active:scale-95 transform-gpu;
border: none;
cursor: pointer;
}
.fab-main:hover {
@apply bg-primary-600 shadow-strong scale-105;
}
.fab-main.is-open {
@apply bg-error-500 rotate-45;
}
.fab-main.is-open:hover {
@apply bg-error-600;
}
.fab-actions-container {
position: absolute;
bottom: 0;
right: 0;
pointer-events: none;
}
.fab-action {
@apply absolute w-12 h-12 rounded-full shadow-medium;
@apply bg-white text-neutral-700 border border-neutral-200;
@apply flex flex-col items-center justify-center;
transition: all 150ms cubic-bezier(0.4, 0, 0.2, 1);
@apply focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500/50;
@apply active:scale-90 transform-gpu;
bottom: 14px;
/* Center relative to main FAB */
right: 14px;
border: none;
cursor: pointer;
pointer-events: all;
}
.fab-action:hover {
@apply bg-primary-50 text-primary-600 shadow-strong scale-110;
}
.fab-action .material-icons {
@apply text-lg leading-none;
}
.fab-action-label {
@apply absolute top-full mt-1 text-xs font-medium whitespace-nowrap;
@apply bg-neutral-900 text-white px-2 py-1 rounded;
@apply opacity-0 pointer-events-none;
@apply transition-opacity duration-micro;
}
.fab-action:hover .fab-action-label {
@apply opacity-100;
}
.fab-backdrop {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.3);
backdrop-filter: blur(2px);
z-index: -1;
}
/* Animations */
.fab-main-enter-active,
.fab-main-leave-active {
transition: all 300ms ease-page;
}
.fab-main-enter-from,
.fab-main-leave-to {
opacity: 0;
transform: scale(0) rotate(180deg);
}
.fab-icon-enter-active,
.fab-icon-leave-active {
transition: all 150ms ease-micro;
}
.fab-icon-enter-from,
.fab-icon-leave-to {
opacity: 0;
transform: rotate(90deg) scale(0.8);
}
.fab-actions-enter-active {
transition: opacity 200ms ease-out;
}
.fab-actions-enter-active .fab-action {
transition: all 300ms ease-page;
}
.fab-actions-leave-active {
transition: opacity 150ms ease-in;
}
.fab-actions-leave-active .fab-action {
transition: all 150ms ease-in;
}
.fab-actions-enter-from,
.fab-actions-leave-to {
opacity: 0;
}
.fab-actions-enter-from .fab-action,
.fab-actions-leave-to .fab-action {
opacity: 0;
transform: translate(0, 0) scale(0.3);
}
.fab-backdrop-enter-active,
.fab-backdrop-leave-active {
transition: all 200ms ease-page;
}
.fab-backdrop-enter-from,
.fab-backdrop-leave-to {
opacity: 0;
backdrop-filter: blur(0);
}
/* Mobile optimizations */
@media (max-width: 640px) {
.universal-fab-container {
bottom: 90px;
/* Above mobile navigation */
right: 16px;
}
.fab-action-label {
display: none;
/* Hide labels on mobile to reduce clutter */
}
}
/* Reduce motion for accessibility */
@media (prefers-reduced-motion: reduce) {
.fab-main,
.fab-action,
.fab-backdrop {
transition: none;
}
.fab-main.is-open {
transform: none;
}
.fab-action:hover {
transform: none;
}
}
</style>

File diff suppressed because it is too large Load Diff

View File

@ -1,56 +1,264 @@
<template> <template>
<div class="p-4 rounded-lg bg-white dark:bg-neutral-800 shadow-sm w-full max-w-screen-md mx-auto"> <Card class="expense-overview">
<Heading :level="2" class="mb-4">{{ $t('expenseOverview.title', 'Expense Overview') }}</Heading> <div v-if="loading" class="loading-state">
<div class="skeleton-header"></div>
<div class="grid grid-cols-2 gap-4 text-sm"> <div class="skeleton-balance"></div>
<div class="flex flex-col items-start p-4 rounded bg-neutral-50 dark:bg-neutral-700/50"> <div class="skeleton-tabs"></div>
<span class="text-neutral-500">{{ $t('expenseOverview.totalExpenses', 'Total Expenses') }}</span> <div class="skeleton-content"></div>
<span class="text-lg font-mono font-semibold">{{ formatCurrency(totalExpenses, currency) }}</span>
</div> </div>
<div class="flex flex-col items-start p-4 rounded bg-neutral-50 dark:bg-neutral-700/50">
<span class="text-neutral-500">{{ $t('expenseOverview.myBalance', 'My Balance') }}</span> <div v-else-if="error" class="error-state">
<span class="text-lg font-mono font-semibold" :class="myBalance < 0 ? 'text-danger' : 'text-success'">{{ <Alert type="error" :message="error" />
formatCurrency(myBalance, currency) }}</span> <Button @click="loadFinancials" class="mt-4">Retry</Button>
</div>
<div v-else class="content-loaded">
<div class="overview-header">
<div class="balance-section">
<span class="balance-label">Your Net Balance</span>
<h2 class="net-balance" :class="balanceColorClass">
{{ formatCurrency(netBalance, currency) }}
</h2>
<p class="balance-subtitle" :class="balanceColorClass">
{{ balanceStatusText }}
</p>
</div>
<div class="overall-spending">
<span class="spending-label">Total Group Spending</span>
<p class="spending-amount">
{{ formatCurrency(totalGroupSpending, currency) }}
</p>
</div> </div>
</div> </div>
<!-- Placeholder for chart --> <Tabs v-model="selectedTab" class="mt-6">
<div class="mt-6 text-center text-neutral-400" v-if="!chartReady"> <div class="tab-list">
{{ $t('expenseOverview.chartPlaceholder', 'Spending chart will appear here…') }} <button v-for="tab in tabs" :key="tab.id"
:class="['tab-item', { 'is-active': selectedTab === tab.id }]" @click="selectedTab = tab.id">
{{ tab.name }}
</button>
</div> </div>
<canvas v-else ref="chartRef" class="w-full h-64" />
<div class="tab-panels">
<div v-if="selectedTab === 'summary'" class="tab-panel">
<ul v-if="debts.length > 0 || credits.length > 0" class="debt-credit-list">
<li v-for="credit in credits" :key="`credit-${credit.user.id}`"
class="list-item text-success">
<span>{{ credit.user.name }} owes you</span>
<span class="font-mono font-semibold">{{ formatCurrency(credit.amount, currency)
}}</span>
</li>
<li v-for="debt in debts" :key="`debt-${debt.user.id}`" class="list-item text-danger">
<span>You owe {{ debt.user.name }}</span>
<span class="font-mono font-semibold">{{ formatCurrency(debt.amount, currency) }}</span>
</li>
</ul>
<div v-else class="empty-tab">
<p>You are all settled up. Good job!</p>
</div> </div>
</div>
<div v-if="selectedTab === 'transactions'" class="tab-panel">
<!-- This would be a list of recent transactions -->
<div class="empty-tab">
<p>Transaction history coming soon.</p>
</div>
</div>
<div v-if="selectedTab === 'graph'" class="tab-panel">
<!-- Placeholder for debt graph -->
<div class="empty-tab">
<p>Debt graph visualization coming soon.</p>
</div>
</div>
</div>
</Tabs>
</div>
</Card>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted, computed } from 'vue' import { ref, onMounted, computed } from 'vue'
import { Heading } from '@/components/ui' import { useAuthStore } from '@/stores/auth'
import { useExpenses } from '@/composables/useExpenses' import { apiClient } from '@/services/api'
import { Card, Button, Alert, Tabs } from '@/components/ui'
import { API_ENDPOINTS } from '@/config/api-config'
// For future chart integration (e.g., Chart.js or ECharts) // Props
const chartRef = ref<HTMLCanvasElement | null>(null) const props = defineProps<{
const chartReady = ref(false) groupId?: number | string
}>()
const { expenses } = useExpenses() // State
const currency = 'USD' const loading = ref(true)
const error = ref<string | null>(null)
const summary = ref<any>(null)
const totalExpenses = computed(() => expenses.value.reduce((sum: number, e: any) => sum + parseFloat(e.total_amount), 0)) type Tab = 'summary' | 'transactions' | 'graph';
const myBalance = ref(0) // Will be provided by backend balances endpoint later const selectedTab = ref<Tab>('summary')
const currency = ref('USD')
function formatCurrency(amount: number, currency: string) { const tabs: { id: Tab; name: string }[] = [
{ id: 'summary', name: 'Summary' },
{ id: 'transactions', name: 'Transactions' },
{ id: 'graph', name: 'Graph' },
]
// API Call
const loadFinancials = async () => {
loading.value = true
error.value = null
try { try {
return new Intl.NumberFormat(undefined, { style: 'currency', currency }).format(amount) const endpoint = props.groupId
} catch { ? API_ENDPOINTS.FINANCIALS.GROUP_SUMMARY(props.groupId.toString())
return amount.toFixed(2) + ' ' + currency : API_ENDPOINTS.FINANCIALS.USER_SUMMARY
const { data } = await apiClient.get(endpoint)
summary.value = data
if (data.currency) {
currency.value = data.currency
}
} catch (err: any) {
error.value = err.response?.data?.detail || 'Failed to load financial summary.'
} finally {
loading.value = false
} }
} }
// Computed Properties
const netBalance = computed(() => summary.value?.net_balance || 0)
const totalGroupSpending = computed(() => summary.value?.total_group_spending || 0)
const debts = computed(() => summary.value?.debts || [])
const credits = computed(() => summary.value?.credits || [])
const balanceColorClass = computed(() => {
if (netBalance.value > 0) return 'text-success'
if (netBalance.value < 0) return 'text-danger'
return 'text-neutral-900'
})
const balanceStatusText = computed(() => {
if (netBalance.value > 0) return "You are owed money"
if (netBalance.value < 0) return "You owe money"
return "You are all settled up"
})
// Formatters
const formatCurrency = (amount: number, currencyCode: string) => {
try {
return new Intl.NumberFormat(undefined, { style: 'currency', currency: currencyCode }).format(amount)
} catch {
return `${amount.toFixed(2)} ${currencyCode}`
}
}
// Lifecycle
onMounted(() => { onMounted(() => {
// TODO: load chart library dynamically & render loadFinancials()
chartReady.value = false
}) })
</script> </script>
<style scoped> <style scoped>
/* Tailwind handles styles */ .expense-overview {
@apply p-6;
}
/* Loading Skeleton */
.loading-state {
@apply animate-pulse space-y-6;
}
.skeleton-header {
@apply h-16 bg-neutral-200 rounded-lg;
}
.skeleton-balance {
@apply h-10 w-3/4 bg-neutral-200 rounded;
}
.skeleton-tabs {
@apply h-8 w-1/2 bg-neutral-200 rounded;
}
.skeleton-content {
@apply h-24 bg-neutral-200 rounded-lg;
}
/* Error State */
.error-state {
@apply text-center;
}
/* Content */
.overview-header {
@apply flex justify-between items-start pb-4 border-b border-neutral-200;
}
.balance-label,
.spending-label {
@apply text-sm text-neutral-600 mb-1;
}
.net-balance {
@apply text-3xl font-bold;
}
.balance-subtitle {
@apply text-sm font-medium;
}
.overall-spending {
@apply text-right;
}
.spending-amount {
@apply text-xl font-semibold text-neutral-800;
}
/* Tabs */
.tab-list {
@apply flex border-b border-neutral-200;
}
.tab-item {
@apply px-4 py-2 text-sm font-medium text-neutral-600 border-b-2 border-transparent;
@apply hover:bg-neutral-100 hover:text-neutral-800 transition-colors;
}
.tab-item.is-active {
@apply text-primary border-primary;
}
.tab-panels {
@apply pt-6;
}
/* Debt/Credit List */
.debt-credit-list {
@apply space-y-3;
}
.list-item {
@apply flex justify-between items-center p-3 bg-neutral-50 rounded-lg;
}
.text-success {
@apply text-green-600;
}
.list-item.text-success {
@apply bg-green-50;
}
.text-danger {
@apply text-red-600;
}
.list-item.text-danger {
@apply bg-red-50;
}
/* Empty State for Tabs */
.empty-tab {
@apply text-center text-neutral-500 py-8;
}
</style> </style>

View File

@ -1,42 +1,707 @@
<template> <template>
<Dialog v-model="open" class="z-50"> <Dialog v-model="open" size="lg" class="z-50">
<div class="bg-white dark:bg-neutral-800 p-6 rounded-lg shadow-xl w-full max-w-md"> <div class="settlement-flow">
<Heading :level="3" class="mb-4">{{ $t('settlementFlow.title', 'Settle Expense') }}</Heading> <!-- Header -->
<header class="settlement-header">
<!-- Placeholder content --> <div class="header-content">
<p class="text-sm text-neutral-500 mb-6">{{ $t('settlementFlow.placeholder', 'Settlement flow coming soon…') <BaseIcon name="heroicons:banknotes" class="header-icon text-primary-500" />
}}</p> <div>
<Heading :level="3" class="settlement-title">Settlement Details</Heading>
<div class="flex justify-end"> <p class="settlement-subtitle">Resolve outstanding balances</p>
<Button variant="ghost" @click="open = false">{{ $t('common.close', 'Close') }}</Button>
</div> </div>
</div> </div>
<Button variant="ghost" size="sm" @click="open = false" class="close-button">
<BaseIcon name="heroicons:x-mark" />
</Button>
</header>
<!-- Progress Steps -->
<div class="steps-indicator">
<div v-for="(step, index) in steps" :key="step.id" class="step-item" :class="{
'step-active': currentStep === index,
'step-completed': currentStep > index
}">
<div class="step-circle">
<BaseIcon v-if="currentStep > index" name="heroicons:check" class="step-check" />
<span v-else class="step-number">{{ index + 1 }}</span>
</div>
<span class="step-label">{{ step.label }}</span>
</div>
</div>
<!-- Step Content -->
<div class="step-content">
<!-- Step 1: Settlement Overview -->
<div v-if="currentStep === 0" class="step-overview">
<div class="debt-summary">
<Card variant="soft" color="primary" class="summary-card">
<div class="summary-header">
<BaseIcon name="heroicons:calculator" />
<h4>Settlement Summary</h4>
</div>
<div class="debt-breakdown">
<div v-for="debt in debts" :key="debt.id" class="debt-item">
<div class="debt-details">
<span class="debt-from">{{ debt.fromUser.name }}</span>
<BaseIcon name="heroicons:arrow-right" class="debt-arrow" />
<span class="debt-to">{{ debt.toUser.name }}</span>
</div>
<div class="debt-amount" :class="getAmountClass(debt.amount)">
{{ formatCurrency(debt.amount) }}
</div>
</div>
</div>
<div class="total-settlement">
<span class="total-label">Total Settlement Amount:</span>
<span class="total-amount">{{ formatCurrency(totalAmount) }}</span>
</div>
</Card>
</div>
<!-- Line Items -->
<div class="line-items">
<h4 class="section-title">What this covers:</h4>
<div class="items-list">
<div v-for="item in settlementItems" :key="item.id" class="settlement-item">
<div class="item-info">
<span class="item-name">{{ item.name }}</span>
<span class="item-date">{{ formatDate(item.date) }}</span>
</div>
<div class="item-amount">{{ formatCurrency(item.amount) }}</div>
</div>
</div>
</div>
</div>
<!-- Step 2: Payment Method -->
<div v-if="currentStep === 1" class="step-payment">
<h4 class="section-title">How was this settled?</h4>
<div class="payment-methods">
<div v-for="method in paymentMethods" :key="method.id" class="payment-option"
:class="{ 'payment-selected': selectedPaymentMethod === method.id }"
@click="selectedPaymentMethod = method.id">
<BaseIcon :name="method.icon" class="payment-icon" />
<div class="payment-details">
<span class="payment-name">{{ method.name }}</span>
<span class="payment-desc">{{ method.description }}</span>
</div>
<div class="payment-radio">
<div class="radio-dot"
:class="{ 'radio-selected': selectedPaymentMethod === method.id }"></div>
</div>
</div>
</div>
<!-- Custom Payment Method -->
<div class="custom-payment">
<Input v-model="customPaymentMethod" label="Other payment method"
placeholder="e.g., Cash, Bank Transfer, etc."
:class="{ 'input-selected': selectedPaymentMethod === 'custom' }"
@focus="selectedPaymentMethod = 'custom'" />
</div>
</div>
<!-- Step 3: Verification -->
<div v-if="currentStep === 2" class="step-verification">
<h4 class="section-title">Settlement Verification</h4>
<div class="verification-section">
<Card variant="soft" color="warning" class="verification-card">
<div class="verification-header">
<BaseIcon name="heroicons:shield-check" />
<span>Verification Required</span>
</div>
<p class="verification-text">
Both parties need to confirm this settlement to prevent disputes.
</p>
</Card>
<!-- Settlement Details Review -->
<div class="settlement-review">
<div class="review-item">
<span class="review-label">Amount:</span>
<span class="review-value">{{ formatCurrency(totalAmount) }}</span>
</div>
<div class="review-item">
<span class="review-label">Method:</span>
<span class="review-value">{{ getSelectedPaymentMethodName() }}</span>
</div>
<div class="review-item">
<span class="review-label">Date:</span>
<span class="review-value">{{ formatDate(new Date()) }}</span>
</div>
</div>
<!-- Receipt Upload -->
<div class="receipt-upload">
<label class="upload-label">
<BaseIcon name="heroicons:camera" />
<span>Add Receipt (Optional)</span>
<input type="file" accept="image/*" @change="handleReceiptUpload" hidden />
</label>
<div v-if="uploadedReceipt" class="uploaded-receipt">
<img :src="uploadedReceipt" alt="Receipt" class="receipt-preview" />
<Button variant="ghost" size="sm" @click="uploadedReceipt = null">
<BaseIcon name="heroicons:x-mark" />
</Button>
</div>
</div>
<!-- Notes -->
<Textarea v-model="settlementNotes" label="Settlement Notes (Optional)"
placeholder="Add any additional details about this settlement..." :rows="3" />
</div>
</div>
</div>
<!-- Footer Actions -->
<footer class="settlement-footer">
<div class="footer-actions">
<Button v-if="currentStep > 0" variant="outline" @click="prevStep" :disabled="processing">
<template #icon-left>
<BaseIcon name="heroicons:arrow-left" />
</template>
Back
</Button>
<div class="primary-actions">
<Button v-if="currentStep < steps.length - 1" @click="nextStep" :disabled="!canProceed"
class="next-button">
Next Step
<template #icon-right>
<BaseIcon name="heroicons:arrow-right" />
</template>
</Button>
<Button v-else @click="confirmSettlement" :loading="processing" :disabled="!canConfirm"
color="success" class="confirm-button">
<template #icon-left>
<BaseIcon name="heroicons:check-circle" />
</template>
Confirm Settlement
</Button>
</div>
</div>
<!-- Dispute Option -->
<div class="dispute-section">
<Button variant="ghost" color="error" size="sm" @click="openDispute">
<BaseIcon name="heroicons:exclamation-triangle" />
Dispute This Settlement
</Button>
</div>
</footer>
</div>
</Dialog>
<!-- Dispute Dialog -->
<Dialog v-model="showDispute" size="md">
<div class="dispute-dialog">
<header class="dispute-header">
<BaseIcon name="heroicons:exclamation-triangle" class="text-warning-500" />
<Heading :level="4">Dispute Settlement</Heading>
</header>
<div class="dispute-content">
<p class="dispute-description">
If you disagree with this settlement, please provide details about the issue:
</p>
<Textarea v-model="disputeReason" label="Reason for dispute"
placeholder="Please explain why you're disputing this settlement..." :rows="4" required />
</div>
<footer class="dispute-footer">
<Button variant="outline" @click="showDispute = false">Cancel</Button>
<Button color="warning" @click="submitDispute" :loading="submittingDispute"
:disabled="!disputeReason.trim()">
Submit Dispute
</Button>
</footer>
</div>
</Dialog> </Dialog>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, reactive } from 'vue' import { ref, reactive, computed, onMounted, watch } from 'vue'
import { Dialog } from '@/components/ui' import { Dialog, Heading, Button, Card, Input, Textarea } from '@/components/ui'
import { Heading, Button } from '@/components/ui' import BaseIcon from '@/components/BaseIcon.vue'
import type { ExpenseSplit, Expense } from '@/types/expense' import type { ExpenseSplit, Expense } from '@/types/expense'
import { useExpenses } from '@/composables/useExpenses' import { useExpenses } from '@/composables/useExpenses'
import { useNotificationStore } from '@/stores/notifications'
import { format } from 'date-fns'
interface Props {
split: ExpenseSplit | null
expense: Expense | null
}
const props = defineProps<Props>()
const open = defineModel<boolean>('modelValue', { default: false }) const open = defineModel<boolean>('modelValue', { default: false })
const props = defineProps<{ split: ExpenseSplit | null, expense: Expense | null }>()
const form = reactive<{ amount: string }>({ amount: '' })
const notifications = useNotificationStore()
const { settleExpenseSplit } = useExpenses() const { settleExpenseSplit } = useExpenses()
async function handleSettle() { // Step management
if (!props.split) return const currentStep = ref(0)
const steps = [
{ id: 'overview', label: 'Overview' },
{ id: 'payment', label: 'Payment Method' },
{ id: 'verification', label: 'Verify & Confirm' }
]
// Settlement data
const totalAmount = ref(125.50) // This would come from props
const debts = ref([
{
id: 1,
fromUser: { name: 'You' },
toUser: { name: 'Alice' },
amount: 75.25
},
{
id: 2,
fromUser: { name: 'You' },
toUser: { name: 'Bob' },
amount: 50.25
}
])
const settlementItems = ref([
{ id: 1, name: 'Grocery Shopping - Whole Foods', date: new Date('2024-01-15'), amount: 89.50 },
{ id: 2, name: 'Dinner at Italian Place', date: new Date('2024-01-18'), amount: 36.00 }
])
// Payment method selection
const selectedPaymentMethod = ref<string>('')
const customPaymentMethod = ref('')
const paymentMethods = [
{ id: 'venmo', name: 'Venmo', description: 'Quick digital transfer', icon: 'heroicons:credit-card' },
{ id: 'paypal', name: 'PayPal', description: 'Secure online payment', icon: 'heroicons:credit-card' },
{ id: 'zelle', name: 'Zelle', description: 'Bank-to-bank transfer', icon: 'heroicons:building-library' },
{ id: 'cash', name: 'Cash', description: 'Physical cash payment', icon: 'heroicons:banknotes' }
]
// Verification
const uploadedReceipt = ref<string | null>(null)
const settlementNotes = ref('')
const processing = ref(false)
// Dispute handling
const showDispute = ref(false)
const disputeReason = ref('')
const submittingDispute = ref(false)
// Computed properties
const canProceed = computed(() => {
if (currentStep.value === 1) {
return selectedPaymentMethod.value.length > 0 &&
(selectedPaymentMethod.value !== 'custom' || customPaymentMethod.value.trim().length > 0)
}
return true
})
const canConfirm = computed(() => {
return selectedPaymentMethod.value.length > 0 &&
(selectedPaymentMethod.value !== 'custom' || customPaymentMethod.value.trim().length > 0)
})
// Methods
function nextStep() {
if (currentStep.value < steps.length - 1) {
currentStep.value++
}
}
function prevStep() {
if (currentStep.value > 0) {
currentStep.value--
}
}
function getAmountClass(amount: number) {
return amount > 0 ? 'text-success-600' : 'text-error-600'
}
function formatCurrency(amount: number) {
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD'
}).format(amount)
}
function formatDate(date: Date) {
return format(date, 'MMM d, yyyy')
}
function getSelectedPaymentMethodName() {
if (selectedPaymentMethod.value === 'custom') {
return customPaymentMethod.value || 'Custom'
}
const method = paymentMethods.find(m => m.id === selectedPaymentMethod.value)
return method?.name || 'Unknown'
}
function handleReceiptUpload(event: Event) {
const file = (event.target as HTMLInputElement).files?.[0]
if (file) {
const reader = new FileReader()
reader.onload = (e) => {
uploadedReceipt.value = e.target?.result as string
}
reader.readAsDataURL(file)
}
}
async function confirmSettlement() {
if (!props.split || !canConfirm.value) return
processing.value = true
try {
await settleExpenseSplit(props.split.id, { await settleExpenseSplit(props.split.id, {
expense_split_id: props.split.id, expense_split_id: props.split.id,
paid_by_user_id: props.split.user_id, paid_by_user_id: props.split.user_id,
amount_paid: form.amount || '0', amount_paid: totalAmount.value.toString()
}) })
notifications.addNotification({
type: 'success',
message: 'Settlement confirmed successfully!'
})
open.value = false open.value = false
resetForm()
} catch (error) {
notifications.addNotification({
type: 'error',
message: 'Failed to confirm settlement. Please try again.'
})
} finally {
processing.value = false
} }
}
function openDispute() {
showDispute.value = true
}
async function submitDispute() {
if (!disputeReason.value.trim()) return
submittingDispute.value = true
try {
// API call to submit dispute
notifications.addNotification({
type: 'success',
message: 'Dispute submitted. Both parties will be notified.'
})
showDispute.value = false
open.value = false
resetForm()
} catch (error) {
notifications.addNotification({
type: 'error',
message: 'Failed to submit dispute. Please try again.'
})
} finally {
submittingDispute.value = false
}
}
function resetForm() {
currentStep.value = 0
selectedPaymentMethod.value = ''
customPaymentMethod.value = ''
uploadedReceipt.value = null
settlementNotes.value = ''
disputeReason.value = ''
}
// Reset form when dialog closes
watch(() => open.value, (newValue: boolean) => {
if (!newValue) {
setTimeout(resetForm, 300) // Delay to allow closing animation
}
})
</script> </script>
<style scoped></style> <style scoped>
.settlement-flow {
@apply bg-surface-primary p-0 rounded-lg w-full max-w-2xl max-h-[90vh] overflow-hidden flex flex-col;
}
/* Header */
.settlement-header {
@apply flex items-center justify-between p-6 border-b border-border-primary bg-surface-soft;
}
.header-content {
@apply flex items-center gap-4;
}
.header-icon {
@apply w-8 h-8;
}
.settlement-title {
@apply text-xl font-semibold text-text-primary;
}
.settlement-subtitle {
@apply text-sm text-text-secondary;
}
.close-button {
@apply w-8 h-8 p-0 rounded-full;
}
/* Steps Indicator */
.steps-indicator {
@apply flex items-center justify-center gap-8 p-6 bg-surface-soft border-b border-border-primary;
}
.step-item {
@apply flex flex-col items-center gap-2 text-center;
}
.step-circle {
@apply w-10 h-10 rounded-full border-2 flex items-center justify-center font-medium text-sm;
@apply border-border-secondary bg-surface-primary text-text-secondary;
@apply transition-all duration-200;
}
.step-item.step-active .step-circle {
@apply border-primary-500 bg-primary-500 text-white;
}
.step-item.step-completed .step-circle {
@apply border-success-500 bg-success-500 text-white;
}
.step-check {
@apply w-5 h-5;
}
.step-label {
@apply text-xs font-medium text-text-secondary;
}
.step-item.step-active .step-label {
@apply text-primary-600;
}
.step-item.step-completed .step-label {
@apply text-success-600;
}
/* Step Content */
.step-content {
@apply flex-1 overflow-y-auto p-6;
}
/* Overview Step */
.debt-summary {
@apply mb-6;
}
.summary-card {
@apply p-6;
}
.summary-header {
@apply flex items-center gap-3 mb-4;
}
.summary-header h4 {
@apply text-lg font-semibold text-text-primary;
}
.debt-breakdown {
@apply space-y-3 mb-4;
}
.debt-item {
@apply flex items-center justify-between p-3 bg-surface-primary rounded-lg;
}
.debt-details {
@apply flex items-center gap-3 text-sm;
}
.debt-arrow {
@apply w-4 h-4 text-text-tertiary;
}
.debt-amount {
@apply font-semibold;
}
.total-settlement {
@apply flex items-center justify-between pt-4 border-t border-border-secondary;
@apply font-semibold text-lg;
}
.line-items {
@apply space-y-4;
}
.section-title {
@apply text-lg font-semibold text-text-primary mb-4;
}
.items-list {
@apply space-y-2;
}
.settlement-item {
@apply flex items-center justify-between p-3 bg-surface-soft rounded-lg;
}
.item-info {
@apply flex flex-col;
}
.item-name {
@apply font-medium text-text-primary;
}
.item-date {
@apply text-sm text-text-secondary;
}
.item-amount {
@apply font-semibold text-text-primary;
}
/* Payment Step */
.payment-methods {
@apply space-y-3 mb-6;
}
.payment-option {
@apply flex items-center gap-4 p-4 border border-border-secondary rounded-lg cursor-pointer;
@apply transition-all duration-200 hover:border-primary-300 hover:bg-surface-soft;
}
.payment-selected {
@apply border-primary-500 bg-primary-50 dark:bg-primary-950/20;
}
.payment-icon {
@apply w-6 h-6 text-text-secondary;
}
.payment-details {
@apply flex-1 flex flex-col gap-1;
}
.payment-name {
@apply font-medium text-text-primary;
}
.payment-desc {
@apply text-sm text-text-secondary;
}
.payment-radio {
@apply w-5 h-5 rounded-full border-2 border-border-secondary flex items-center justify-center;
}
.radio-dot {
@apply w-2 h-2 rounded-full bg-transparent transition-colors duration-200;
}
.radio-selected {
@apply bg-primary-500;
}
.custom-payment {
@apply pt-4 border-t border-border-secondary;
}
/* Verification Step */
.verification-section {
@apply space-y-6;
}
.verification-card {
@apply p-4;
}
.verification-header {
@apply flex items-center gap-3 mb-2;
}
.verification-text {
@apply text-sm text-text-secondary;
}
.settlement-review {
@apply space-y-3 p-4 bg-surface-soft rounded-lg;
}
.review-item {
@apply flex items-center justify-between;
}
.review-label {
@apply text-text-secondary;
}
.review-value {
@apply font-medium text-text-primary;
}
.receipt-upload {
@apply space-y-3;
}
.upload-label {
@apply flex items-center gap-3 p-4 border-2 border-dashed border-border-secondary rounded-lg cursor-pointer;
@apply transition-colors duration-200 hover:border-primary-300 hover:bg-surface-soft;
}
.uploaded-receipt {
@apply flex items-center gap-3 p-3 bg-surface-soft rounded-lg;
}
.receipt-preview {
@apply w-16 h-16 object-cover rounded-md;
}
/* Footer */
.settlement-footer {
@apply p-6 border-t border-border-primary bg-surface-soft space-y-4;
}
.footer-actions {
@apply flex items-center justify-between;
}
.primary-actions {
@apply flex items-center gap-3;
}
.dispute-section {
@apply text-center;
}
/* Dispute Dialog */
.dispute-dialog {
@apply bg-surface-primary p-6 rounded-lg w-full max-w-md;
}
.dispute-header {
@apply flex items-center gap-3 mb-4;
}
.dispute-content {
@apply space-y-4 mb-6;
}
.dispute-description {
@apply text-text-secondary;
}
.dispute-footer {
@apply flex items-center justify-end gap-3;
}
</style>

View File

@ -0,0 +1,492 @@
<template>
<Dialog v-model="visible" size="lg">
<div class="conflict-resolution-dialog">
<!-- Header -->
<div class="dialog-header">
<div class="header-content">
<div class="conflict-icon">
<BaseIcon name="heroicons:exclamation-triangle-20-solid" class="warning-icon" />
</div>
<div class="header-text">
<Heading :level="3" class="dialog-title">Conflict Detected</Heading>
<p class="dialog-description">
{{ conflict?.action?.type }} conflict with server data. Choose how to resolve.
</p>
</div>
</div>
<div class="conflict-info">
<span class="conflict-type">{{ conflict?.action }} Operation</span>
<span class="conflict-time">{{ formatConflictTime }}</span>
</div>
</div>
<!-- Comparison View -->
<div class="comparison-container">
<div class="comparison-grid">
<!-- Local Version -->
<div class="version-panel local-panel">
<div class="panel-header">
<div class="panel-title">
<BaseIcon name="heroicons:device-phone-mobile-20-solid" class="panel-icon" />
<span class="panel-label">Your Changes</span>
</div>
<div class="version-timestamp">{{ formatLocalTime }}</div>
</div>
<div class="version-content">
<div v-if="localChanges.length > 0" class="changes-list">
<div v-for="change in localChanges" :key="change.field" class="change-item">
<div class="change-field">{{ change.field }}</div>
<div class="change-value local-value">{{ change.value }}</div>
</div>
</div>
<div v-else class="no-changes">No specific changes detected</div>
</div>
<div class="panel-actions">
<Button variant="solid" color="primary" @click="keepLocal" :loading="isResolving"
class="resolution-button">
<template #icon-left>
<BaseIcon name="heroicons:check-20-solid" />
</template>
Keep Your Changes
</Button>
</div>
</div>
<!-- Server Version -->
<div class="version-panel server-panel">
<div class="panel-header">
<div class="panel-title">
<BaseIcon name="heroicons:cloud-20-solid" class="panel-icon" />
<span class="panel-label">Server Version</span>
</div>
<div class="version-timestamp">{{ formatServerTime }}</div>
</div>
<div class="version-content">
<div v-if="serverChanges.length > 0" class="changes-list">
<div v-for="change in serverChanges" :key="change.field" class="change-item">
<div class="change-field">{{ change.field }}</div>
<div class="change-value server-value">{{ change.value }}</div>
</div>
</div>
<div v-else class="no-changes">No server changes</div>
</div>
<div class="panel-actions">
<Button variant="outline" color="neutral" @click="keepServer" :loading="isResolving"
class="resolution-button">
<template #icon-left>
<BaseIcon name="heroicons:arrow-down-tray-20-solid" />
</template>
Use Server Version
</Button>
</div>
</div>
</div>
<!-- Diff View Toggle -->
<div class="diff-controls">
<Button variant="ghost" size="sm" @click="showRawDiff = !showRawDiff" class="diff-toggle">
<template #icon-left>
<BaseIcon :name="showRawDiff ? 'heroicons:eye-slash-20-solid' : 'heroicons:eye-20-solid'" />
</template>
{{ showRawDiff ? 'Hide' : 'Show' }} Raw Data
</Button>
</div>
<!-- Raw Diff View -->
<Transition name="slide-down">
<div v-if="showRawDiff" class="raw-diff-container">
<div class="diff-grid">
<div class="diff-panel">
<h5 class="diff-title">Local JSON</h5>
<pre class="diff-content local-diff">{{ localJSON }}</pre>
</div>
<div class="diff-panel">
<h5 class="diff-title">Server JSON</h5>
<pre class="diff-content server-diff">{{ serverJSON }}</pre>
</div>
</div>
</div>
</Transition>
</div>
<!-- Footer Actions -->
<div class="dialog-footer">
<div class="footer-info">
<BaseIcon name="heroicons:information-circle-20-solid" class="info-icon" />
<span class="info-text">This conflict won't affect other users until resolved.</span>
</div>
<div class="footer-actions">
<Button variant="ghost" color="neutral" @click="dismissConflict" :disabled="isResolving">
Skip for Now
</Button>
<Button variant="outline" color="warning" @click="mergeManually" :disabled="isResolving">
<template #icon-left>
<BaseIcon name="heroicons:code-bracket-20-solid" />
</template>
Merge Manually
</Button>
</div>
</div>
</div>
</Dialog>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import { formatDistanceToNow, parseISO } from 'date-fns'
import { Dialog, Button, Card, Heading } from '@/components/ui'
import BaseIcon from '@/components/BaseIcon.vue'
import { useOfflineStore } from '@/stores/offline'
import { useNotificationStore } from '@/stores/notifications'
const offlineStore = useOfflineStore()
const notificationStore = useNotificationStore()
const isResolving = ref(false)
const showRawDiff = ref(false)
const visible = computed({
get: () => offlineStore.showConflictDialog,
set: (val) => (offlineStore.showConflictDialog = val),
})
const conflict = computed(() => offlineStore.currentConflict)
// Format timestamps
const formatConflictTime = computed(() => {
if (!conflict.value?.action?.timestamp) return 'Unknown time'
return formatDistanceToNow(new Date(conflict.value.action.timestamp), { addSuffix: true })
})
const formatLocalTime = computed(() => {
if (!conflict.value?.localVersion.timestamp) return 'Local changes'
return formatDistanceToNow(new Date(conflict.value.localVersion.timestamp), { addSuffix: true })
})
const formatServerTime = computed(() => {
if (!conflict.value?.serverVersion.timestamp) return 'Server version'
return formatDistanceToNow(new Date(conflict.value.serverVersion.timestamp), { addSuffix: true })
})
// JSON representations
const localJSON = computed(() =>
JSON.stringify(conflict.value?.localVersion.data ?? {}, null, 2)
)
const serverJSON = computed(() =>
JSON.stringify(conflict.value?.serverVersion.data ?? {}, null, 2)
)
// Parse changes for better display
const localChanges = computed(() => {
if (!conflict.value?.localVersion.data) return []
const data = conflict.value.localVersion.data
return Object.entries(data)
.filter(([key, value]) => key !== 'id' && value != null)
.map(([field, value]) => ({
field: formatFieldName(field),
value: formatFieldValue(value)
}))
})
const serverChanges = computed(() => {
if (!conflict.value?.serverVersion.data) return []
const data = conflict.value.serverVersion.data
return Object.entries(data)
.filter(([key, value]) => key !== 'id' && value != null)
.map(([field, value]) => ({
field: formatFieldName(field),
value: formatFieldValue(value)
}))
})
// Helper functions
const formatFieldName = (field: string): string => {
return field
.replace(/_/g, ' ')
.replace(/([A-Z])/g, ' $1')
.replace(/^./, str => str.toUpperCase())
.trim()
}
const formatFieldValue = (value: any): string => {
if (value === null || value === undefined) return 'None'
if (typeof value === 'boolean') return value ? 'Yes' : 'No'
if (typeof value === 'object') return JSON.stringify(value)
return String(value)
}
// Resolution actions
const keepLocal = async () => {
if (!conflict.value || isResolving.value) return
isResolving.value = true
try {
await offlineStore.handleConflictResolution({
version: 'local',
action: conflict.value.action
})
notificationStore.addNotification({
type: 'success',
message: 'Conflict resolved with your changes'
})
} catch (error) {
notificationStore.addNotification({
type: 'error',
message: 'Failed to resolve conflict'
})
} finally {
isResolving.value = false
}
}
const keepServer = async () => {
if (!conflict.value || isResolving.value) return
isResolving.value = true
try {
await offlineStore.handleConflictResolution({
version: 'server',
action: conflict.value.action
})
notificationStore.addNotification({
type: 'success',
message: 'Conflict resolved with server version'
})
} catch (error) {
notificationStore.addNotification({
type: 'error',
message: 'Failed to resolve conflict'
})
} finally {
isResolving.value = false
}
}
const mergeManually = async () => {
// For now, just keep local and notify user
await keepLocal()
notificationStore.addNotification({
type: 'info',
message: 'Manual merge functionality coming soon. Using your changes for now.'
})
}
const dismissConflict = () => {
visible.value = false
notificationStore.addNotification({
type: 'warning',
message: 'Conflict postponed. Will retry on next sync.'
})
}
</script>
<style scoped>
.conflict-resolution-dialog {
@apply max-w-4xl w-full;
}
.dialog-header {
@apply border-b border-border-primary pb-6 mb-6;
}
.header-content {
@apply flex items-start gap-4 mb-4;
}
.conflict-icon {
@apply flex-shrink-0 w-12 h-12 bg-warning-100 dark:bg-warning-900/30 rounded-lg;
@apply flex items-center justify-center;
}
.warning-icon {
@apply w-6 h-6 text-warning-600;
}
.header-text {
@apply flex-1;
}
.dialog-title {
@apply text-text-primary mb-2;
}
.dialog-description {
@apply text-text-secondary text-sm leading-relaxed;
}
.conflict-info {
@apply flex items-center gap-4 text-sm;
}
.conflict-type {
@apply px-3 py-1 bg-warning-100 dark:bg-warning-900/30 text-warning-700 dark:text-warning-300;
@apply rounded-full font-medium;
}
.conflict-time {
@apply text-text-tertiary;
}
/* Comparison View */
.comparison-container {
@apply space-y-6;
}
.comparison-grid {
@apply grid grid-cols-1 lg:grid-cols-2 gap-6;
}
.version-panel {
@apply border border-border-primary rounded-lg bg-surface-primary;
}
.local-panel {
@apply border-primary-300 bg-primary-50/50 dark:bg-primary-900/10;
}
.server-panel {
@apply border-neutral-300 bg-neutral-50/50 dark:bg-neutral-900/10;
}
.panel-header {
@apply flex items-center justify-between p-4 border-b border-border-primary;
}
.panel-title {
@apply flex items-center gap-2;
}
.panel-icon {
@apply w-5 h-5 text-text-secondary;
}
.panel-label {
@apply font-semibold text-text-primary;
}
.version-timestamp {
@apply text-xs text-text-tertiary;
}
.version-content {
@apply p-4 min-h-[120px];
}
.changes-list {
@apply space-y-3;
}
.change-item {
@apply space-y-1;
}
.change-field {
@apply text-xs font-medium text-text-secondary uppercase tracking-wider;
}
.change-value {
@apply text-sm font-mono bg-surface-secondary px-3 py-2 rounded border;
@apply break-words;
}
.local-value {
@apply border-primary-200 bg-primary-50 dark:bg-primary-900/20;
}
.server-value {
@apply border-neutral-200 bg-neutral-50 dark:bg-neutral-900/20;
}
.no-changes {
@apply text-text-tertiary text-sm italic text-center py-8;
}
.panel-actions {
@apply p-4 border-t border-border-primary;
}
.resolution-button {
@apply w-full;
}
/* Diff Controls */
.diff-controls {
@apply flex justify-center;
}
.diff-toggle {
@apply text-text-secondary;
}
/* Raw Diff */
.raw-diff-container {
@apply border border-border-primary rounded-lg bg-surface-secondary;
}
.diff-grid {
@apply grid grid-cols-1 lg:grid-cols-2;
}
.diff-panel {
@apply border-r border-border-primary last:border-r-0;
}
.diff-title {
@apply px-4 py-3 border-b border-border-primary bg-surface-secondary;
@apply text-sm font-semibold text-text-primary;
}
.diff-content {
@apply p-4 text-xs font-mono overflow-auto max-h-64;
@apply text-text-primary bg-surface-primary;
}
.local-diff {
@apply bg-primary-50/30 dark:bg-primary-900/10;
}
.server-diff {
@apply bg-neutral-50/30 dark:bg-neutral-900/10;
}
/* Footer */
.dialog-footer {
@apply flex items-center justify-between pt-6 border-t border-border-primary mt-6;
}
.footer-info {
@apply flex items-center gap-2 text-sm text-text-tertiary;
}
.info-icon {
@apply w-4 h-4;
}
.footer-actions {
@apply flex items-center gap-3;
}
/* Animations */
.slide-down-enter-active,
.slide-down-leave-active {
@apply transition-all duration-300;
}
.slide-down-enter-from,
.slide-down-leave-to {
@apply opacity-0 transform -translate-y-4;
}
</style>

View File

@ -13,7 +13,9 @@
@checkbox-change="(item, checked) => $emit('checkbox-change', item, checked)" @checkbox-change="(item, checked) => $emit('checkbox-change', item, checked)"
@update-price="$emit('update-price', item)" @start-edit="$emit('start-edit', item)" @update-price="$emit('update-price', item)" @start-edit="$emit('start-edit', item)"
@save-edit="$emit('save-edit', item)" @cancel-edit="$emit('cancel-edit', item)" @save-edit="$emit('save-edit', item)" @cancel-edit="$emit('cancel-edit', item)"
@update:editName="item.editName = $event" @update:editQuantity="item.editQuantity = $event" @update-quantity="(item, qty) => $emit('update-quantity', item, qty)"
@mark-bought="handleMarkBought" @update:editName="item.editName = $event"
@update:editQuantity="item.editQuantity = $event"
@update:editCategoryId="item.editCategoryId = $event" @update:editCategoryId="item.editCategoryId = $event"
@update:priceInput="item.priceInput = $event" :list="list" /> @update:priceInput="item.priceInput = $event" :list="list" />
</template> </template>
@ -43,7 +45,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, defineProps, defineEmits } from 'vue'; import { ref, computed, defineProps, defineEmits, onMounted } from 'vue';
import type { PropType } from 'vue'; import type { PropType } from 'vue';
import draggable from 'vuedraggable'; import draggable from 'vuedraggable';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
@ -56,6 +58,10 @@ import { Listbox } from '@headlessui/vue'; // This might not be needed if we mak
import Button from '@/components/ui/Button.vue'; import Button from '@/components/ui/Button.vue';
import Input from '@/components/ui/Input.vue'; import Input from '@/components/ui/Input.vue';
import BaseIcon from '@/components/BaseIcon.vue'; import BaseIcon from '@/components/BaseIcon.vue';
import PurchaseConfirmationDialog from './PurchaseConfirmationDialog.vue'
import { useOptimisticUpdates } from '@/composables/useOptimisticUpdates'
import { useSocket } from '@/composables/useSocket'
import { enqueue } from '@/utils/offlineQueue'
interface ItemWithUI extends Item { interface ItemWithUI extends Item {
updating: boolean; updating: boolean;
@ -112,6 +118,8 @@ const emit = defineEmits([
'handle-drag-end', 'handle-drag-end',
'update:newItemName', 'update:newItemName',
'update:newItemCategoryId', 'update:newItemCategoryId',
'update-quantity',
'mark-bought',
]); ]);
const { t } = useI18n(); const { t } = useI18n();
@ -122,10 +130,13 @@ const safeCategoryOptions = computed(() => props.categoryOptions.map(opt => ({
value: opt.value === null ? '' : opt.value value: opt.value === null ? '' : opt.value
}))); })));
// Optimistic updates wrapper so UI responds instantly
const { data: optimisticItems, mutate: optimisticMutate } = useOptimisticUpdates<ItemWithUI>(props.items)
const groupedItems = computed(() => { const groupedItems = computed(() => {
const groups: Record<string, { categoryName: string; items: ItemWithUI[] }> = {}; const groups: Record<string, { categoryName: string; items: ItemWithUI[] }> = {};
props.items.forEach(item => { optimisticItems.value.forEach(item => {
const categoryId = item.category_id; const categoryId = item.category_id;
const category = props.categories.find(c => c.id === categoryId); const category = props.categories.find(c => c.id === categoryId);
const categoryName = category ? category.name : t('listDetailPage.items.noCategory'); const categoryName = category ? category.name : t('listDetailPage.items.noCategory');
@ -181,6 +192,27 @@ defineExpose({
focusNewItemInput focusNewItemInput
}); });
// === Real-time sync ===
const { on: onSocket, emit: wsEmit } = useSocket()
onMounted(() => {
onSocket('lists:item:update', (payload: any) => {
// Basic handler: update local optimistic list when other user updates.
if (!payload?.id) return
optimisticMutate((draft) => draft.map((i) => (i.id === payload.id ? { ...i, ...payload } : i)))
})
})
function handleMarkBought(item: ItemWithUI) {
// Optimistic update local state
optimisticMutate((draft) => draft.map((i) => (i.id === item.id ? { ...i, is_complete: true } : i)))
wsEmit('lists:item:update', { id: item.id, action: 'bought' })
if (!props.isOnline) {
enqueue({ type: 'item-bought', payload: { id: item.id } })
}
}
</script> </script>
<style scoped> <style scoped>

View File

@ -1,16 +1,8 @@
<template> <template>
<div class="list-item-wrapper" :class="{ 'is-complete': item.is_complete }"> <div class="list-item-wrapper" :class="{ 'is-complete': item.is_complete }">
<div class="list-item-content"> <div class="list-item-content">
<div class="drag-handle" v-if="isOnline"> <div class="drag-handle text-neutral-400 dark:text-neutral-500" v-if="isOnline">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" <BaseIcon name="heroicons:bars-2" class="w-5 h-5" />
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="9" cy="12" r="1"></circle>
<circle cx="9" cy="5" r="1"></circle>
<circle cx="9" cy="19" r="1"></circle>
<circle cx="15" cy="12" r="1"></circle>
<circle cx="15" cy="5" r="1"></circle>
<circle cx="15" cy="19" r="1"></circle>
</svg>
</div> </div>
<div class="item-main-content"> <div class="item-main-content">
@ -30,15 +22,25 @@
Unclaim Unclaim
</VButton> </VButton>
<VInput v-if="item.is_complete" type="number" :model-value="item.price" <Input v-if="item.is_complete" type="number" :model-value="item.price"
@update:model-value="$emit('update-price', item, $event)" placeholder="Price" class="price-input" /> @update:modelValue="$emit('update-price', item, $event)" placeholder="0.00" class="w-20" />
<VButton @click="$emit('edit-item', item)" variant="ghost" size="sm" aria-label="Edit Item"> <Button variant="ghost" size="sm" @click="$emit('edit-item', item)" aria-label="Edit Item">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" <BaseIcon name="heroicons:pencil-square" />
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> </Button>
<path d="M12 20h9"></path> <!-- Quantity stepper -->
<path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z"></path> <div class="flex items-center gap-1 ml-2">
</svg> <Button variant="outline" size="xs" @click="decreaseQty" :disabled="(item.quantity ?? 1) <= 1">
</VButton> <BaseIcon name="heroicons:minus-small" />
</Button>
<span class="min-w-[1.5rem] text-center text-sm">{{ item.quantity ?? 1 }}</span>
<Button variant="outline" size="xs" @click="increaseQty">
<BaseIcon name="heroicons:plus-small" />
</Button>
</div>
<!-- Mark bought button appears when claimed_by_me and not purchased -->
<Button v-if="showMarkBought" variant="solid" color="success" size="sm" @click="markBought">
<BaseIcon name="heroicons:shopping-cart" />
</Button>
</div> </div>
</div> </div>
</div> </div>
@ -52,9 +54,11 @@ import type { List } from '@/types/list';
import { useListsStore } from '@/stores/listsStore'; import { useListsStore } from '@/stores/listsStore';
import { useAuthStore } from '@/stores/auth'; import { useAuthStore } from '@/stores/auth';
import { formatDistanceToNow } from 'date-fns'; import { formatDistanceToNow } from 'date-fns';
import VCheckbox from '@/components/valerie/VCheckbox.vue'; import Checkbox from '@/components/ui/Switch.vue';
import VButton from '@/components/valerie/VButton.vue'; import Button from '@/components/ui/Button.vue';
import VInput from '@/components/valerie/VInput.vue'; import Input from '@/components/ui/Input.vue';
import BaseIcon from '@/components/BaseIcon.vue';
import { useSocket } from '@/composables/useSocket';
const props = defineProps({ const props = defineProps({
item: { item: {
@ -68,15 +72,13 @@ const props = defineProps({
isOnline: Boolean, isOnline: Boolean,
}); });
const emit = defineEmits(['delete-item', 'checkbox-change', 'update-price', 'edit-item']); const emit = defineEmits(['delete-item', 'checkbox-change', 'update-price', 'edit-item', 'update-quantity', 'mark-bought']);
const listsStore = useListsStore(); const listsStore = useListsStore();
const authStore = useAuthStore(); const authStore = useAuthStore();
const currentUser = computed(() => authStore.user); const currentUser = computed(() => authStore.user);
const canClaim = computed(() => { const canClaim = computed(() => props.list.group_id && !props.item.is_complete && !props.item.claimed_by_user_id);
return props.list.group_id && !props.item.is_complete && !props.item.claimed_by_user_id;
});
const canUnclaim = computed(() => { const canUnclaim = computed(() => {
return props.item.claimed_by_user_id === currentUser.value?.id; return props.item.claimed_by_user_id === currentUser.value?.id;
@ -92,15 +94,29 @@ const claimStatus = computed(() => {
const handleClaim = () => { const handleClaim = () => {
if (!props.list.group_id) return; // Should not happen if button is shown, but good practice if (!props.list.group_id) return; // Should not happen if button is shown, but good practice
listsStore.claimItem(props.item.id); listsStore.claimItem(props.item.id);
socket.emit('lists:item:update', { id: props.item.id, action: 'claim' });
}; };
const handleUnclaim = () => { const handleUnclaim = () => {
listsStore.unclaimItem(props.item.id); listsStore.unclaimItem(props.item.id);
socket.emit('lists:item:update', { id: props.item.id, action: 'unclaim' });
}; };
const onCheckboxChange = (checked: boolean) => { const onCheckboxChange = (checked: boolean) => {
emit('checkbox-change', props.item, checked); emit('checkbox-change', props.item, checked);
}; };
const socket = useSocket();
const showMarkBought = computed(() => props.item.claimed_by_user_id === currentUser.value?.id && !props.item.is_complete);
const markBought = () => {
emit('mark-bought', props.item);
socket.emit('lists:item:update', { id: props.item.id, action: 'bought' });
};
const increaseQty = () => emit('update-quantity', props.item, (props.item.quantity ?? 1) + 1);
const decreaseQty = () => emit('update-quantity', props.item, (props.item.quantity ?? 1) - 1);
</script> </script>
<style scoped> <style scoped>

View File

@ -0,0 +1,545 @@
<template>
<div class="item-composer">
<!-- Quick Add Bar -->
<div class="composer-container" :class="{ 'composer-focused': isFocused, 'composer-listening': isListening }">
<div class="input-section">
<div class="input-wrapper">
<BaseIcon name="heroicons:plus-20-solid" class="add-icon" />
<input ref="inputRef" v-model="itemText" type="text"
placeholder="Add item... (e.g., 2x Organic Milk)" class="item-input" @focus="handleFocus"
@blur="handleBlur" @keydown="handleKeydown" @input="handleInput" autocomplete="off" />
<div class="input-actions">
<!-- Voice Input Button -->
<Button v-if="supportsSpeechRecognition" variant="ghost" size="sm"
:class="{ 'listening': isListening }" @click="toggleVoiceInput">
<BaseIcon
:name="isListening ? 'heroicons:stop-20-solid' : 'heroicons:microphone-20-solid'" />
</Button>
<!-- Add Button -->
<Button variant="solid" color="primary" size="sm" :disabled="!itemText.trim()" @click="addItem">
Add
</Button>
</div>
</div>
<!-- Smart Suggestions Dropdown -->
<Transition name="dropdown">
<div v-if="showSuggestions && filteredSuggestions.length > 0" class="suggestions-dropdown">
<div class="suggestions-header">
<span class="suggestions-title">Suggestions</span>
<span class="suggestions-count">{{ filteredSuggestions.length }}</span>
</div>
<div class="suggestions-list">
<button v-for="(suggestion, index) in filteredSuggestions" :key="suggestion.id"
:class="['suggestion-item', { 'highlighted': highlightedIndex === index }]"
@click="selectSuggestion(suggestion)" @mouseenter="highlightedIndex = index">
<div class="suggestion-content">
<div class="suggestion-main">
<span class="suggestion-name">{{ suggestion.name }}</span>
<span v-if="suggestion.brand" class="suggestion-brand">{{ suggestion.brand
}}</span>
</div>
<div class="suggestion-meta">
<span v-if="suggestion.category" class="suggestion-category">{{
suggestion.category }}</span>
<span v-if="suggestion.lastPrice" class="suggestion-price">${{
suggestion.lastPrice }}</span>
</div>
</div>
<div class="suggestion-stats">
<span class="suggestion-frequency">{{ suggestion.frequency }}x</span>
</div>
</button>
</div>
</div>
</Transition>
</div>
<!-- Smart Context Bar -->
<Transition name="slide-down">
<div v-if="isFocused && !itemText" class="context-bar">
<div class="context-tabs">
<button v-for="tab in contextTabs" :key="tab.id"
:class="['context-tab', { 'active': activeTab === tab.id }]" @click="activeTab = tab.id">
<BaseIcon :name="tab.icon" class="tab-icon" />
<span class="tab-label">{{ tab.label }}</span>
<span v-if="tab.count" class="tab-count">{{ tab.count }}</span>
</button>
</div>
<div class="context-content">
<!-- Recent Items -->
<div v-if="activeTab === 'recent'" class="quick-add-grid">
<button v-for="item in recentItems" :key="item.id" class="quick-add-chip"
@click="selectSuggestion(item)">
<span class="chip-text">{{ item.name }}</span>
<BaseIcon name="heroicons:plus-20-solid" class="chip-icon" />
</button>
</div>
<!-- Categories -->
<div v-else-if="activeTab === 'categories'" class="categories-grid">
<button v-for="category in popularCategories" :key="category.id" class="category-chip"
@click="setCategory(category)">
<BaseIcon :name="category.icon" class="category-icon" />
<span class="category-name">{{ category.name }}</span>
</button>
</div>
<!-- Voice Commands -->
<div v-else-if="activeTab === 'voice'" class="voice-guide">
<div class="voice-examples">
<p class="voice-instruction">Try saying:</p>
<ul class="voice-commands">
<li>"Add two pounds of ground beef"</li>
<li>"Three bottles of olive oil"</li>
<li>"Organic bananas"</li>
</ul>
</div>
</div>
</div>
</div>
</Transition>
</div>
<!-- Voice Feedback -->
<Transition name="fade">
<div v-if="isListening" class="voice-feedback">
<div class="voice-indicator">
<div class="voice-wave"></div>
<div class="voice-wave"></div>
<div class="voice-wave"></div>
</div>
<span class="voice-text">{{ voiceStatus }}</span>
</div>
</Transition>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import BaseIcon from '@/components/BaseIcon.vue'
import { Button } from '@/components/ui'
interface ItemSuggestion {
id: string
name: string
brand?: string
category?: string
lastPrice?: number
frequency: number
}
interface Category {
id: string
name: string
icon: string
}
interface Props {
listId: string
storeLocation?: string
}
const props = defineProps<Props>()
const emit = defineEmits<{
'item-added': [item: any]
}>()
// Component state
const inputRef = ref<HTMLInputElement>()
const itemText = ref('')
const isFocused = ref(false)
const isListening = ref(false)
const voiceStatus = ref('Listening...')
const activeTab = ref<'recent' | 'categories' | 'voice'>('recent')
const highlightedIndex = ref(-1)
// Voice recognition basic support check
const supportsSpeechRecognition = computed(() => false) // Simplified for now
// Suggestions and context
const suggestions = ref<ItemSuggestion[]>([])
const showSuggestions = computed(() => isFocused.value && itemText.value.length > 0)
const filteredSuggestions = computed(() => {
if (!itemText.value) return []
const searchTerm = itemText.value.toLowerCase()
return suggestions.value
.filter(s => s.name.toLowerCase().includes(searchTerm))
.sort((a, b) => b.frequency - a.frequency)
.slice(0, 6)
})
const recentItems = computed(() =>
suggestions.value
.sort((a, b) => b.frequency - a.frequency)
.slice(0, 8)
)
const popularCategories = computed<Category[]>(() => [
{ id: 'produce', name: 'Produce', icon: 'heroicons:heart-20-solid' },
{ id: 'dairy', name: 'Dairy', icon: 'heroicons:cube-20-solid' },
{ id: 'meat', name: 'Meat & Seafood', icon: 'heroicons:fire-20-solid' },
{ id: 'pantry', name: 'Pantry', icon: 'heroicons:archive-box-20-solid' },
{ id: 'frozen', name: 'Frozen', icon: 'heroicons:snowflake-20-solid' },
{ id: 'household', name: 'Household', icon: 'heroicons:home-20-solid' }
])
const contextTabs = computed(() => [
{ id: 'recent' as const, label: 'Recent', icon: 'heroicons:clock-20-solid', count: recentItems.value.length },
{ id: 'categories' as const, label: 'Categories', icon: 'heroicons:tag-20-solid' },
{ id: 'voice' as const, label: 'Voice', icon: 'heroicons:microphone-20-solid' }
])
// Event handlers
const handleFocus = () => {
isFocused.value = true
loadSuggestions()
}
const handleBlur = () => {
// Delay to allow clicks on suggestions
setTimeout(() => {
isFocused.value = false
highlightedIndex.value = -1
}, 200)
}
const handleInput = () => {
highlightedIndex.value = -1
}
const handleKeydown = (event: KeyboardEvent) => {
if (!showSuggestions.value) return
switch (event.key) {
case 'ArrowDown':
event.preventDefault()
highlightedIndex.value = Math.min(
highlightedIndex.value + 1,
filteredSuggestions.value.length - 1
)
break
case 'ArrowUp':
event.preventDefault()
highlightedIndex.value = Math.max(highlightedIndex.value - 1, -1)
break
case 'Enter':
event.preventDefault()
if (highlightedIndex.value >= 0) {
selectSuggestion(filteredSuggestions.value[highlightedIndex.value])
} else {
addItem()
}
break
case 'Escape':
isFocused.value = false
inputRef.value?.blur()
break
}
}
const toggleVoiceInput = () => {
// Voice input would be implemented here
console.log('Voice input toggle')
}
const selectSuggestion = (suggestion: ItemSuggestion) => {
itemText.value = suggestion.name
addItem()
}
const setCategory = (category: Category) => {
// Could set a category filter or prefix
inputRef.value?.focus()
}
const addItem = async () => {
if (!itemText.value.trim()) return
const itemData = {
name: itemText.value.trim(),
quantity: 1,
list_id: props.listId,
claimed_by_user_id: null
}
// Emit the item for parent to handle
emit('item-added', itemData)
itemText.value = ''
// Update suggestions frequency
updateSuggestionFrequency(itemData.name)
}
const loadSuggestions = async () => {
// Mock suggestions for now
suggestions.value = [
{ id: '1', name: 'Organic Milk', category: 'Dairy', frequency: 5, lastPrice: 4.99 },
{ id: '2', name: 'Bananas', category: 'Produce', frequency: 8, lastPrice: 2.49 },
{ id: '3', name: 'Bread', category: 'Bakery', frequency: 3, lastPrice: 3.49 },
{ id: '4', name: 'Eggs', category: 'Dairy', frequency: 4, lastPrice: 5.99 },
{ id: '5', name: 'Chicken Breast', category: 'Meat', frequency: 2, lastPrice: 8.99 }
]
}
const updateSuggestionFrequency = (itemName: string) => {
const existing = suggestions.value.find(s => s.name.toLowerCase() === itemName.toLowerCase())
if (existing) {
existing.frequency++
} else {
suggestions.value.push({
id: `suggestion-${Date.now()}`,
name: itemName,
frequency: 1
})
}
}
// Lifecycle
onMounted(() => {
loadSuggestions()
})
</script>
<style scoped>
.item-composer {
@apply relative;
}
.composer-container {
@apply bg-surface-primary border border-border-primary rounded-lg transition-all duration-micro;
@apply hover:border-border-hover;
}
.composer-focused {
@apply border-primary-500 ring-2 ring-primary-500/20;
}
.composer-listening {
@apply border-accent-500 ring-2 ring-accent-500/20;
}
.input-section {
@apply relative;
}
.input-wrapper {
@apply flex items-center gap-3 p-4;
}
.add-icon {
@apply w-5 h-5 text-text-secondary flex-shrink-0;
}
.item-input {
@apply flex-1 bg-transparent border-0 outline-none text-text-primary placeholder-text-tertiary;
@apply text-sm leading-5;
}
.input-actions {
@apply flex items-center gap-2;
}
.listening {
@apply text-accent-500 bg-accent-50 dark:bg-accent-500/10;
}
/* Suggestions dropdown */
.suggestions-dropdown {
@apply absolute top-full left-0 right-0 bg-surface-primary border border-border-primary rounded-lg shadow-strong;
@apply mt-2 py-2 z-50 max-h-80 overflow-y-auto;
}
.suggestions-header {
@apply flex items-center justify-between px-4 py-2 border-b border-border-primary;
}
.suggestions-title {
@apply text-xs font-medium text-text-secondary uppercase tracking-wider;
}
.suggestions-count {
@apply text-xs text-text-tertiary;
}
.suggestion-item {
@apply w-full flex items-center justify-between px-4 py-3 hover:bg-surface-secondary;
@apply transition-colors duration-micro cursor-pointer;
}
.suggestion-item.highlighted {
@apply bg-primary-50 dark:bg-primary-500/10;
}
.suggestion-content {
@apply flex-1 min-w-0;
}
.suggestion-main {
@apply flex items-center gap-2 mb-1;
}
.suggestion-name {
@apply font-medium text-text-primary truncate;
}
.suggestion-brand {
@apply text-xs text-text-secondary bg-surface-tertiary px-2 py-0.5 rounded;
}
.suggestion-meta {
@apply flex items-center gap-3 text-xs text-text-tertiary;
}
.suggestion-stats {
@apply text-xs text-text-tertiary font-medium;
}
/* Context bar */
.context-bar {
@apply border-t border-border-primary bg-surface-secondary;
}
.context-tabs {
@apply flex border-b border-border-primary;
}
.context-tab {
@apply flex items-center gap-2 px-4 py-3 text-sm font-medium text-text-secondary;
@apply hover:text-text-primary hover:bg-surface-primary transition-colors duration-micro;
}
.context-tab.active {
@apply text-primary-600 bg-primary-50 border-b-2 border-primary-500;
}
.tab-count {
@apply text-xs bg-surface-tertiary text-text-tertiary px-1.5 py-0.5 rounded;
}
.context-content {
@apply p-4;
}
/* Quick add chips */
.quick-add-grid {
@apply grid grid-cols-2 sm:grid-cols-4 gap-2;
}
.quick-add-chip {
@apply flex items-center justify-between px-3 py-2 bg-surface-primary border border-border-primary;
@apply rounded-md hover:border-primary-300 hover:bg-primary-50 transition-colors duration-micro;
}
.chip-text {
@apply text-sm text-text-primary truncate;
}
.chip-icon {
@apply w-4 h-4 text-text-tertiary ml-2;
}
/* Categories */
.categories-grid {
@apply grid grid-cols-3 sm:grid-cols-6 gap-3;
}
.category-chip {
@apply flex flex-col items-center gap-2 p-3 bg-surface-primary border border-border-primary;
@apply rounded-lg hover:border-primary-300 hover:bg-primary-50 transition-colors duration-micro;
}
.category-icon {
@apply w-6 h-6 text-text-secondary;
}
.category-name {
@apply text-xs font-medium text-text-primary text-center;
}
/* Voice feedback */
.voice-feedback {
@apply flex items-center gap-3 mt-4 p-4 bg-accent-50 dark:bg-accent-500/10 rounded-lg;
}
.voice-indicator {
@apply flex items-center gap-1;
}
.voice-wave {
@apply w-1 h-4 bg-accent-500 rounded-full;
animation: voice-pulse 1.5s ease-in-out infinite;
}
.voice-wave:nth-child(2) {
animation-delay: 0.2s;
}
.voice-wave:nth-child(3) {
animation-delay: 0.4s;
}
.voice-text {
@apply text-sm font-medium text-accent-700 dark:text-accent-300;
}
/* Voice guide */
.voice-guide {
@apply text-center;
}
.voice-instruction {
@apply text-sm font-medium text-text-primary mb-3;
}
.voice-commands {
@apply text-sm text-text-secondary space-y-1;
}
/* Animations */
.dropdown-enter-active,
.dropdown-leave-active {
@apply transition-all duration-200;
}
.dropdown-enter-from,
.dropdown-leave-to {
@apply opacity-0 transform scale-95;
}
.slide-down-enter-active,
.slide-down-leave-active {
@apply transition-all duration-300;
}
.slide-down-enter-from,
.slide-down-leave-to {
@apply opacity-0 transform -translate-y-2;
}
.fade-enter-active,
.fade-leave-active {
@apply transition-opacity duration-200;
}
.fade-enter-from,
.fade-leave-to {
@apply opacity-0;
}
@keyframes voice-pulse {
0%,
100% {
height: 1rem;
}
50% {
height: 1.5rem;
}
}
</style>

View File

@ -0,0 +1,32 @@
<template>
<Dialog v-model="open" size="sm">
<div class="p-6 space-y-4 w-full max-w-sm">
<Heading :level="4">Confirm Purchase</Heading>
<p class="text-sm text-text-secondary">Did you buy these items?</p>
<ul class="list-disc list-inside text-sm">
<li v-for="i in items" :key="i.id">{{ i.name }} × {{ i.quantity }}</li>
</ul>
<div class="flex gap-3 justify-end">
<Button variant="outline" @click="open = false">Cancel</Button>
<Button color="success" @click="confirm">Confirm</Button>
</div>
</div>
</Dialog>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { Dialog, Button, Heading } from '@/components/ui'
interface ItemLite { id: number | string; name: string; quantity?: number | string }
const props = defineProps<{ items: ItemLite[] }>()
const emit = defineEmits<{ (e: 'confirm'): void }>()
const open = ref(true)
function confirm() {
emit('confirm')
open.value = false
}
</script>

View File

@ -1,6 +1,8 @@
<template> <template>
<button :type="type" :class="buttonClasses" :disabled="disabled"> <button :type="type" :class="buttonClasses" :disabled="disabled" @click="handleClick">
<slot name="icon-left" />
<slot /> <slot />
<slot name="icon-right" />
</button> </button>
</template> </template>
@ -9,15 +11,19 @@ import { computed } from 'vue'
export interface ButtonProps { export interface ButtonProps {
/** Visual variant */ /** Visual variant */
variant?: 'solid' | 'outline' | 'ghost' variant?: 'solid' | 'outline' | 'ghost' | 'soft'
/** Color token */ /** Color token */
color?: 'primary' | 'secondary' | 'success' | 'warning' | 'danger' | 'neutral' color?: 'primary' | 'secondary' | 'success' | 'warning' | 'error' | 'neutral'
/** Size preset */ /** Size preset */
size?: 'sm' | 'md' | 'lg' size?: 'xs' | 'sm' | 'md' | 'lg' | 'xl'
/** HTML button type */ /** HTML button type */
type?: 'button' | 'submit' | 'reset' type?: 'button' | 'submit' | 'reset'
/** Disabled state */ /** Disabled state */
disabled?: boolean disabled?: boolean
/** Loading state */
loading?: boolean
/** Full width */
fullWidth?: boolean
} }
const props = withDefaults(defineProps<ButtonProps>(), { const props = withDefaults(defineProps<ButtonProps>(), {
@ -26,48 +32,205 @@ const props = withDefaults(defineProps<ButtonProps>(), {
size: 'md', size: 'md',
type: 'button', type: 'button',
disabled: false, disabled: false,
loading: false,
fullWidth: false,
}) })
const emit = defineEmits<{
click: [event: MouseEvent]
}>()
const handleClick = (event: MouseEvent) => {
if (!props.disabled && !props.loading) {
emit('click', event)
}
}
/** /**
* Tailwind class maps keep in sync with design tokens in `tailwind.config.ts`. * Tailwind class maps using new design tokens
*/ */
const base = const base = `
'inline-flex items-center justify-center font-medium rounded focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 transition disabled:opacity-50 disabled:pointer-events-none' inline-flex items-center justify-center gap-2 font-medium rounded-lg
focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2
transition-all duration-micro ease-micro
disabled:opacity-50 disabled:pointer-events-none
active:scale-[0.98] transform-gpu
`.trim().replace(/\s+/g, ' ')
const sizeClasses: Record<string, string> = { const sizeClasses: Record<string, string> = {
sm: 'px-2.5 py-1.5 text-xs', xs: 'px-2 py-1 text-xs h-6',
md: 'px-4 py-2 text-sm', sm: 'px-3 py-1.5 text-sm h-8',
lg: 'px-6 py-3 text-base', md: 'px-4 py-2 text-sm h-10',
lg: 'px-6 py-3 text-base h-12',
xl: 'px-8 py-4 text-lg h-14',
} }
const colorMatrix: Record<string, Record<string, string>> = { const colorMatrix: Record<string, Record<string, string>> = {
solid: { solid: {
primary: 'bg-primary text-white hover:bg-primary/90', primary: `
secondary: 'bg-secondary text-white hover:bg-secondary/90', bg-primary-500 text-white shadow-soft
success: 'bg-success text-white hover:bg-success/90', hover:bg-primary-600 hover:shadow-medium
warning: 'bg-warning text-white hover:bg-warning/90', focus-visible:ring-primary-500/50
danger: 'bg-danger text-white hover:bg-danger/90', active:bg-primary-700
neutral: 'bg-neutral text-white hover:bg-neutral/90', `.trim().replace(/\s+/g, ' '),
secondary: `
bg-secondary text-white shadow-soft
hover:bg-secondary/90 hover:shadow-medium
focus-visible:ring-secondary/50
`.trim().replace(/\s+/g, ' '),
success: `
bg-success-500 text-white shadow-soft
hover:bg-success-600 hover:shadow-medium
focus-visible:ring-success-500/50
active:bg-success-700
`.trim().replace(/\s+/g, ' '),
warning: `
bg-warning-500 text-white shadow-soft
hover:bg-warning-600 hover:shadow-medium
focus-visible:ring-warning-500/50
active:bg-warning-700
`.trim().replace(/\s+/g, ' '),
error: `
bg-error-500 text-white shadow-soft
hover:bg-error-600 hover:shadow-medium
focus-visible:ring-error-500/50
active:bg-error-700
`.trim().replace(/\s+/g, ' '),
neutral: `
bg-neutral-500 text-white shadow-soft
hover:bg-neutral-600 hover:shadow-medium
focus-visible:ring-neutral-500/50
active:bg-neutral-700
`.trim().replace(/\s+/g, ' '),
}, },
outline: { outline: {
primary: 'border border-primary text-primary hover:bg-primary/10', primary: `
secondary: 'border border-secondary text-secondary hover:bg-secondary/10', border border-primary-300 text-primary-600 bg-white
success: 'border border-success text-success hover:bg-success/10', hover:bg-primary-50 hover:border-primary-400
warning: 'border border-warning text-warning hover:bg-warning/10', focus-visible:ring-primary-500/50
danger: 'border border-danger text-danger hover:bg-danger/10', active:bg-primary-100
neutral: 'border border-neutral text-neutral hover:bg-neutral/10', `.trim().replace(/\s+/g, ' '),
secondary: `
border border-gray-300 text-secondary bg-white
hover:bg-gray-50 hover:border-gray-400
focus-visible:ring-secondary/50
`.trim().replace(/\s+/g, ' '),
success: `
border border-success-300 text-success-600 bg-white
hover:bg-success-50 hover:border-success-400
focus-visible:ring-success-500/50
active:bg-success-100
`.trim().replace(/\s+/g, ' '),
warning: `
border border-warning-300 text-warning-600 bg-white
hover:bg-warning-50 hover:border-warning-400
focus-visible:ring-warning-500/50
active:bg-warning-100
`.trim().replace(/\s+/g, ' '),
error: `
border border-error-300 text-error-600 bg-white
hover:bg-error-50 hover:border-error-400
focus-visible:ring-error-500/50
active:bg-error-100
`.trim().replace(/\s+/g, ' '),
neutral: `
border border-neutral-300 text-neutral-600 bg-white
hover:bg-neutral-50 hover:border-neutral-400
focus-visible:ring-neutral-500/50
active:bg-neutral-100
`.trim().replace(/\s+/g, ' '),
}, },
ghost: { ghost: {
primary: 'text-primary hover:bg-primary/10', primary: `
secondary: 'text-secondary hover:bg-secondary/10', text-primary-600 bg-transparent
success: 'text-success hover:bg-success/10', hover:bg-primary-50
warning: 'text-warning hover:bg-warning/10', focus-visible:ring-primary-500/50
danger: 'text-danger hover:bg-danger/10', active:bg-primary-100
neutral: 'text-neutral hover:bg-neutral/10', `.trim().replace(/\s+/g, ' '),
secondary: `
text-secondary bg-transparent
hover:bg-gray-50
focus-visible:ring-secondary/50
`.trim().replace(/\s+/g, ' '),
success: `
text-success-600 bg-transparent
hover:bg-success-50
focus-visible:ring-success-500/50
active:bg-success-100
`.trim().replace(/\s+/g, ' '),
warning: `
text-warning-600 bg-transparent
hover:bg-warning-50
focus-visible:ring-warning-500/50
active:bg-warning-100
`.trim().replace(/\s+/g, ' '),
error: `
text-error-600 bg-transparent
hover:bg-error-50
focus-visible:ring-error-500/50
active:bg-error-100
`.trim().replace(/\s+/g, ' '),
neutral: `
text-neutral-600 bg-transparent
hover:bg-neutral-50
focus-visible:ring-neutral-500/50
active:bg-neutral-100
`.trim().replace(/\s+/g, ' '),
},
soft: {
primary: `
bg-primary-100 text-primary-700
hover:bg-primary-200
focus-visible:ring-primary-500/50
active:bg-primary-300
`.trim().replace(/\s+/g, ' '),
secondary: `
bg-gray-100 text-secondary
hover:bg-gray-200
focus-visible:ring-secondary/50
`.trim().replace(/\s+/g, ' '),
success: `
bg-success-100 text-success-700
hover:bg-success-200
focus-visible:ring-success-500/50
active:bg-success-300
`.trim().replace(/\s+/g, ' '),
warning: `
bg-warning-100 text-warning-700
hover:bg-warning-200
focus-visible:ring-warning-500/50
active:bg-warning-300
`.trim().replace(/\s+/g, ' '),
error: `
bg-error-100 text-error-700
hover:bg-error-200
focus-visible:ring-error-500/50
active:bg-error-300
`.trim().replace(/\s+/g, ' '),
neutral: `
bg-neutral-100 text-neutral-700
hover:bg-neutral-200
focus-visible:ring-neutral-500/50
active:bg-neutral-300
`.trim().replace(/\s+/g, ' '),
}, },
} }
const buttonClasses = computed(() => { const buttonClasses = computed(() => {
return [base, sizeClasses[props.size], colorMatrix[props.variant][props.color]].join(' ') const classes = [
base,
sizeClasses[props.size],
colorMatrix[props.variant][props.color],
]
if (props.fullWidth) {
classes.push('w-full')
}
if (props.loading) {
classes.push('cursor-wait')
}
return classes.join(' ')
}) })
</script> </script>

View File

@ -1,9 +1,142 @@
<template> <template>
<div class="bg-white dark:bg-neutral-800 border dark:border-neutral-700 rounded-lg shadow-sm p-4"> <div :class="cardClasses">
<slot /> <slot />
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
// A simple card component for content containment. import { computed } from 'vue'
export interface CardProps {
/** Visual variant */
variant?: 'elevated' | 'outlined' | 'filled' | 'soft'
/** Color scheme */
color?: 'neutral' | 'primary' | 'success' | 'warning' | 'error'
/** Padding size */
padding?: 'none' | 'sm' | 'md' | 'lg' | 'xl'
/** Border radius */
rounded?: 'none' | 'sm' | 'md' | 'lg' | 'xl' | 'full'
/** Interactive state */
interactive?: boolean
/** Disabled state */
disabled?: boolean
}
const props = withDefaults(defineProps<CardProps>(), {
variant: 'elevated',
color: 'neutral',
padding: 'md',
rounded: 'lg',
interactive: false,
disabled: false,
})
/**
* Tailwind class maps using new design tokens
*/
const baseClasses = `
relative transition-all duration-micro ease-micro
disabled:opacity-50 disabled:pointer-events-none
`.trim().replace(/\s+/g, ' ')
const variantClasses: Record<string, string> = {
elevated: 'bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 shadow-soft',
outlined: 'bg-transparent border-2 border-neutral-300 dark:border-neutral-700',
filled: 'bg-neutral-50 dark:bg-neutral-800 border border-neutral-200 dark:border-neutral-700',
soft: 'bg-white/60 dark:bg-neutral-900/60 backdrop-blur-sm border border-white/20 dark:border-neutral-700/30 shadow-soft',
}
const colorClasses: Record<string, Record<string, string>> = {
neutral: {
elevated: 'bg-white dark:bg-neutral-900 border-neutral-200 dark:border-neutral-800',
outlined: 'border-neutral-300 dark:border-neutral-700 text-neutral-900 dark:text-neutral-100',
filled: 'bg-neutral-50 dark:bg-neutral-800 border-neutral-200 dark:border-neutral-700',
soft: 'bg-white/60 dark:bg-neutral-900/60 border-white/20 dark:border-neutral-700/30',
},
primary: {
elevated: 'bg-primary-50 dark:bg-primary-950 border-primary-200 dark:border-primary-800',
outlined: 'border-primary-300 dark:border-primary-700 text-primary-900 dark:text-primary-100',
filled: 'bg-primary-100 dark:bg-primary-900 border-primary-200 dark:border-primary-800',
soft: 'bg-primary-50/60 dark:bg-primary-950/60 border-primary-200/30 dark:border-primary-800/30',
},
success: {
elevated: 'bg-success-50 dark:bg-success-950 border-success-200 dark:border-success-800',
outlined: 'border-success-300 dark:border-success-700 text-success-900 dark:text-success-100',
filled: 'bg-success-100 dark:bg-success-900 border-success-200 dark:border-success-800',
soft: 'bg-success-50/60 dark:bg-success-950/60 border-success-200/30 dark:border-success-800/30',
},
warning: {
elevated: 'bg-warning-50 dark:bg-warning-950 border-warning-200 dark:border-warning-800',
outlined: 'border-warning-300 dark:border-warning-700 text-warning-900 dark:text-warning-100',
filled: 'bg-warning-100 dark:bg-warning-900 border-warning-200 dark:border-warning-800',
soft: 'bg-warning-50/60 dark:bg-warning-950/60 border-warning-200/30 dark:border-warning-800/30',
},
error: {
elevated: 'bg-error-50 dark:bg-error-950 border-error-200 dark:border-error-800',
outlined: 'border-error-300 dark:border-error-700 text-error-900 dark:text-error-100',
filled: 'bg-error-100 dark:bg-error-900 border-error-200 dark:border-error-800',
soft: 'bg-error-50/60 dark:bg-error-950/60 border-error-200/30 dark:border-error-800/30',
},
}
const paddingClasses: Record<string, string> = {
none: 'p-0',
sm: 'p-3',
md: 'p-4',
lg: 'p-6',
xl: 'p-8',
}
const roundedClasses: Record<string, string> = {
none: 'rounded-none',
sm: 'rounded-sm',
md: 'rounded-md',
lg: 'rounded-lg',
xl: 'rounded-xl',
full: 'rounded-full',
}
const interactiveClasses = `
cursor-pointer hover:shadow-medium hover:scale-[1.02] active:scale-[0.98]
focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500 focus-visible:ring-offset-2
transform-gpu
`.trim().replace(/\s+/g, ' ')
const cardClasses = computed(() => {
const classes = [baseClasses]
// Base variant and color
const variantClass = variantClasses[props.variant] || variantClasses.elevated
const colorClass = colorClasses[props.color]?.[props.variant] || ''
// Replace color-specific classes if color is not neutral
if (props.color !== 'neutral') {
classes.push(variantClass.replace(/bg-\S+|border-\S+|text-\S+/g, ''))
classes.push(colorClass)
} else {
classes.push(variantClass)
}
// Padding
classes.push(paddingClasses[props.padding] || paddingClasses.md)
// Rounded
classes.push(roundedClasses[props.rounded] || roundedClasses.lg)
// Interactive
if (props.interactive && !props.disabled) {
classes.push(interactiveClasses)
}
// Disabled
if (props.disabled) {
classes.push('opacity-50 pointer-events-none')
}
return classes.join(' ')
})
</script> </script>
<style scoped>
/* Additional styles if needed */
</style>

View File

@ -0,0 +1,153 @@
<template>
<div class="checkbox-wrapper">
<div class="checkbox-container">
<input :id="id" v-model="internalValue" type="checkbox" :disabled="disabled" :required="required"
class="checkbox-input" :class="checkboxClasses" v-bind="$attrs" />
<div class="checkbox-indicator">
<BaseIcon v-if="internalValue" name="heroicons:check" class="check-icon" />
</div>
</div>
<label v-if="label || $slots.default" :for="id" class="checkbox-label" :class="labelClasses">
<slot>{{ label }}</slot>
</label>
</div>
</template>
<script setup lang="ts">
import { computed, useAttrs } from 'vue'
import BaseIcon from '@/components/BaseIcon.vue'
interface Props {
modelValue?: boolean
label?: string
disabled?: boolean
required?: boolean
size?: 'sm' | 'md' | 'lg'
color?: 'primary' | 'success' | 'warning' | 'error'
id?: string
}
const props = withDefaults(defineProps<Props>(), {
modelValue: false,
size: 'md',
color: 'primary',
id: () => `checkbox-${Math.random().toString(36).substr(2, 9)}`
})
const emit = defineEmits<{
'update:modelValue': [value: boolean]
}>()
const attrs = useAttrs()
const internalValue = computed({
get: () => props.modelValue,
set: (value: boolean) => emit('update:modelValue', value)
})
const checkboxClasses = computed(() => [
'checkbox-base',
`checkbox-${props.size}`,
`checkbox-${props.color}`,
{
'checkbox-disabled': props.disabled,
'checkbox-checked': internalValue.value
}
])
const labelClasses = computed(() => [
'label-base',
`label-${props.size}`,
{
'label-disabled': props.disabled
}
])
</script>
<style scoped>
.checkbox-wrapper {
@apply flex items-start gap-3;
}
.checkbox-container {
@apply relative flex-shrink-0;
}
.checkbox-input {
@apply sr-only;
}
.checkbox-indicator {
@apply flex items-center justify-center transition-all duration-micro;
@apply border-2 rounded-md cursor-pointer;
@apply border-border-primary bg-surface-primary;
@apply hover:border-primary-300 focus-within:ring-2 focus-within:ring-primary-500/20;
}
/* Size variants */
.checkbox-base+.checkbox-indicator {
@apply w-5 h-5;
}
.checkbox-sm+.checkbox-indicator {
@apply w-4 h-4;
}
.checkbox-lg+.checkbox-indicator {
@apply w-6 h-6;
}
/* Color variants */
.checkbox-primary:checked+.checkbox-indicator {
@apply bg-primary-500 border-primary-500;
}
.checkbox-success:checked+.checkbox-indicator {
@apply bg-success-500 border-success-500;
}
.checkbox-warning:checked+.checkbox-indicator {
@apply bg-warning-500 border-warning-500;
}
.checkbox-error:checked+.checkbox-indicator {
@apply bg-error-500 border-error-500;
}
.checkbox-disabled+.checkbox-indicator {
@apply opacity-50 cursor-not-allowed;
}
.check-icon {
@apply w-3 h-3 text-white;
}
.checkbox-sm+.checkbox-indicator .check-icon {
@apply w-2.5 h-2.5;
}
.checkbox-lg+.checkbox-indicator .check-icon {
@apply w-4 h-4;
}
/* Label styles */
.checkbox-label {
@apply cursor-pointer select-none;
}
.label-base {
@apply text-sm font-medium text-text-primary;
}
.label-sm {
@apply text-xs;
}
.label-lg {
@apply text-base;
}
.label-disabled {
@apply opacity-50 cursor-not-allowed;
}
</style>

View File

@ -1,40 +1,396 @@
<template> <template>
<div> <div class="input-container">
<label v-if="label" :for="id" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">{{ label <!-- Label -->
}}</label> <label v-if="label" :for="inputId" class="input-label" :class="labelClasses">
<input :id="id" v-bind="$attrs" :type="type" :class="inputClasses" v-model="modelValueProxy" {{ label }}
:aria-invalid="error ? 'true' : undefined" /> <span v-if="required" class="required-indicator" aria-label="required">*</span>
<p v-if="error" class="mt-1 text-sm text-danger">{{ error }}</p> </label>
<!-- Input Container -->
<div class="input-wrapper" :class="wrapperClasses">
<!-- Prefix Icon -->
<div v-if="prefixIcon || slots.prefix" class="input-prefix">
<slot name="prefix">
<span v-if="prefixIcon" class="material-icons input-icon">{{ prefixIcon }}</span>
</slot>
</div>
<!-- Input Element -->
<input :id="inputId" ref="inputRef" v-bind="$attrs" :type="inputType" :value="modelValue"
:placeholder="placeholder" :disabled="disabled || loading" :readonly="readonly" :required="required"
:autocomplete="autocomplete" :aria-invalid="hasError ? 'true' : undefined"
:aria-describedby="descriptionId" class="input-field" :class="inputClasses" @input="handleInput"
@blur="handleBlur" @focus="handleFocus" @keydown="handleKeydown" />
<!-- Suffix Content -->
<div v-if="suffixIcon || loading || hasError || hasSuccess || showPassword || slots.suffix"
class="input-suffix">
<!-- Loading Spinner -->
<div v-if="loading" class="input-loading">
<svg class="animate-spin h-4 w-4 text-neutral-400" fill="none" viewBox="0 0 24 24">
<circle cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" class="opacity-25">
</circle>
<path fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
class="opacity-75"></path>
</svg>
</div>
<!-- Success Indicator -->
<div v-else-if="hasSuccess && !hasError" class="input-success">
<span class="material-icons input-icon text-success-500">check_circle</span>
</div>
<!-- Error Indicator -->
<div v-else-if="hasError" class="input-error-icon">
<span class="material-icons input-icon text-error-500">error</span>
</div>
<!-- Password Toggle -->
<button v-else-if="showPassword" type="button" class="input-password-toggle"
@click="togglePasswordVisibility" :aria-label="passwordVisible ? 'Hide password' : 'Show password'">
<span class="material-icons input-icon">{{ passwordVisible ? 'visibility_off' : 'visibility'
}}</span>
</button>
<!-- Suffix Icon or Slot -->
<div v-else-if="suffixIcon || slots.suffix" class="input-suffix-content">
<slot name="suffix">
<span v-if="suffixIcon" class="material-icons input-icon">{{ suffixIcon }}</span>
</slot>
</div>
</div>
</div>
<!-- Help Text / Error Message -->
<div v-if="helpText || error" :id="descriptionId" class="input-description">
<div v-if="error" class="input-error-text">
<span class="material-icons input-error-icon-small">error</span>
{{ error }}
</div>
<div v-else-if="helpText" class="input-help-text">
{{ helpText }}
</div>
</div>
<!-- Character Count -->
<div v-if="maxLength && showCharCount" class="input-char-count">
<span :class="characterCountClasses">
{{ characterCount }}/{{ maxLength }}
</span>
</div>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed, toRefs } from 'vue' import { computed, ref, nextTick, watch, useSlots } from 'vue'
interface Props { interface Props {
modelValue: string | number | null modelValue?: string | number | null
label?: string label?: string
placeholder?: string
type?: string type?: string
error?: string | null error?: string | null
helpText?: string
disabled?: boolean
readonly?: boolean
required?: boolean
loading?: boolean
success?: boolean
prefixIcon?: string
suffixIcon?: string
autocomplete?: string
maxLength?: number
showCharCount?: boolean
size?: 'sm' | 'md' | 'lg'
variant?: 'default' | 'soft' | 'ghost'
validateOnBlur?: boolean
validateOnInput?: boolean
id?: string id?: string
} }
const props = withDefaults(defineProps<Props>(), { const props = withDefaults(defineProps<Props>(), {
type: 'text', type: 'text',
error: null, size: 'md',
}) variant: 'default',
const emit = defineEmits(['update:modelValue']) validateOnBlur: true,
validateOnInput: false,
const { modelValue } = toRefs(props) showCharCount: false,
const modelValueProxy = computed({
get: () => modelValue.value,
set: (val) => emit('update:modelValue', val),
}) })
const base = 'shadow-sm block w-full sm:text-sm rounded-md' const emit = defineEmits<{
const theme = 'border-gray-300 dark:border-neutral-600 dark:bg-neutral-800 focus:ring-primary focus:border-primary' 'update:modelValue': [value: string | number | null]
const errorCls = 'border-danger focus:border-danger focus:ring-danger' 'focus': [event: FocusEvent]
const inputClasses = computed(() => [base, theme, props.error ? errorCls : ''].join(' ')) 'blur': [event: FocusEvent]
'input': [event: Event]
'keydown': [event: KeyboardEvent]
'validate': [value: string | number | null]
}>()
// Refs and Slots
const slots = useSlots()
const inputRef = ref<HTMLInputElement | null>(null)
const isFocused = ref(false)
const passwordVisible = ref(false)
// Computed Properties
const inputId = computed(() => props.id || `input-${Math.random().toString(36).substr(2, 9)}`)
const descriptionId = computed(() => `${inputId.value}-description`)
const hasError = computed(() => Boolean(props.error))
const hasSuccess = computed(() => props.success && !hasError.value)
const showPassword = computed(() => props.type === 'password')
const inputType = computed(() => {
if (props.type === 'password') {
return passwordVisible.value ? 'text' : 'password'
}
return props.type
})
const characterCount = computed(() => {
if (props.modelValue == null) return 0
return String(props.modelValue).length
})
const characterCountClasses = computed(() => [
'text-xs font-mono',
{
'text-error-500': props.maxLength && characterCount.value > props.maxLength,
'text-warning-500': props.maxLength && characterCount.value > props.maxLength * 0.8,
'text-neutral-500': !props.maxLength || characterCount.value <= props.maxLength * 0.8,
}
])
// Label Classes
const labelClasses = computed(() => [
'block text-sm font-medium mb-2 transition-colors duration-micro',
{
'text-neutral-700': !hasError.value && !isFocused.value,
'text-primary-600': isFocused.value && !hasError.value,
'text-error-600': hasError.value,
'opacity-50': props.disabled,
}
])
// Wrapper Classes
const wrapperClasses = computed(() => [
'relative flex items-center transition-all duration-micro',
{
'opacity-50': props.disabled,
}
])
// Input Classes
const inputClasses = computed(() => {
const sizeClasses = {
sm: 'h-9 text-sm px-3',
md: 'h-11 text-base px-4',
lg: 'h-13 text-lg px-5',
}
const variantClasses = {
default: 'bg-white border border-neutral-300',
soft: 'bg-neutral-50 border border-neutral-200',
ghost: 'bg-transparent border border-transparent',
}
return [
// Base styles
'block w-full rounded-lg font-sans transition-all duration-micro ease-micro',
'placeholder:text-neutral-400 placeholder:font-normal',
'focus:outline-none focus:ring-2 focus:ring-offset-0',
// Size classes
sizeClasses[props.size],
// Variant classes
variantClasses[props.variant],
// State classes
{
// Focus states
'focus:ring-primary-500/20 focus:border-primary-500': !hasError.value && !props.disabled,
'focus:ring-error-500/20 focus:border-error-500': hasError.value && !props.disabled,
// Error states
'border-error-300 bg-error-50': hasError.value && props.variant === 'default',
'border-error-200 bg-error-25': hasError.value && props.variant === 'soft',
// Success states
'border-success-300 bg-success-50': hasSuccess.value && props.variant === 'default',
'border-success-200 bg-success-25': hasSuccess.value && props.variant === 'soft',
// Disabled states
'cursor-not-allowed bg-neutral-100 text-neutral-500': props.disabled,
'cursor-default': props.readonly,
// Loading states
'pr-10': props.loading || hasError.value || hasSuccess.value || showPassword.value,
'pl-10': props.prefixIcon || !!slots.prefix,
}
]
})
// Methods
const handleInput = (event: Event) => {
const target = event.target as HTMLInputElement
const value = target.value
emit('update:modelValue', value)
emit('input', event)
if (props.validateOnInput) {
emit('validate', value)
}
}
const handleBlur = (event: FocusEvent) => {
isFocused.value = false
emit('blur', event)
if (props.validateOnBlur) {
emit('validate', props.modelValue ?? null)
}
}
const handleFocus = (event: FocusEvent) => {
isFocused.value = true
emit('focus', event)
}
const handleKeydown = (event: KeyboardEvent) => {
emit('keydown', event)
}
const togglePasswordVisibility = () => {
passwordVisible.value = !passwordVisible.value
}
const focus = () => {
nextTick(() => {
inputRef.value?.focus()
})
}
const blur = () => {
inputRef.value?.blur()
}
const select = () => {
inputRef.value?.select()
}
// Watch for maxLength validation
watch(() => characterCount.value, (newCount) => {
if (props.maxLength && newCount > props.maxLength) {
emit('validate', props.modelValue ?? null)
}
})
// Expose methods
defineExpose({
focus,
blur,
select,
inputRef,
})
</script> </script>
<style scoped></style> <style scoped>
.input-container {
@apply w-full;
}
.input-label {
user-select: none;
}
.required-indicator {
@apply text-error-500 ml-1;
}
.input-wrapper {
@apply relative;
}
.input-field {
/* Remove default appearance */
-webkit-appearance: none;
-moz-appearance: none;
appearance: none;
}
.input-field::-webkit-outer-spin-button,
.input-field::-webkit-inner-spin-button {
-webkit-appearance: none;
margin: 0;
}
.input-field[type=number] {
-moz-appearance: textfield;
}
.input-prefix,
.input-suffix {
@apply absolute inset-y-0 flex items-center pointer-events-none;
}
.input-prefix {
@apply left-3;
}
.input-suffix {
@apply right-3;
}
.input-icon {
@apply text-lg text-neutral-400;
}
.input-loading {
@apply pointer-events-none;
}
.input-password-toggle {
@apply pointer-events-auto p-1 rounded text-neutral-400 hover:text-neutral-600 transition-colors duration-micro;
}
.input-description {
@apply mt-2;
}
.input-error-text {
@apply flex items-center gap-1 text-sm text-error-600;
}
.input-error-icon-small {
@apply text-base;
}
.input-help-text {
@apply text-sm text-neutral-500;
}
.input-char-count {
@apply mt-1 text-right;
}
/* Focus ring for accessibility */
.input-field:focus {
@apply ring-2 ring-offset-0;
}
/* Animation for success/error states */
.input-success,
.input-error-icon {
animation: scaleIn 150ms ease-out;
}
@keyframes scaleIn {
0% {
opacity: 0;
transform: scale(0.8);
}
100% {
opacity: 1;
transform: scale(1);
}
}
</style>

View File

@ -0,0 +1,73 @@
<template>
<div class="textarea-wrapper">
<label v-if="label" :for="id" class="textarea-label">{{ label }}</label>
<div class="relative">
<textarea :id="id" :value="modelValue"
@input="$emit('update:modelValue', ($event.target as HTMLTextAreaElement).value)"
:placeholder="placeholder" :rows="rows" :disabled="disabled" :class="textareaClasses"></textarea>
</div>
<p v-if="description" class="textarea-description">{{ description }}</p>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
interface Props {
modelValue: string
id?: string
label?: string
placeholder?: string
rows?: number
disabled?: boolean
description?: string
variant?: 'default' | 'error'
}
const props = withDefaults(defineProps<Props>(), {
id: () => `textarea-${Math.random().toString(36).substring(2, 9)}`,
rows: 4,
disabled: false,
variant: 'default',
})
defineEmits(['update:modelValue'])
const baseClasses = `
w-full px-3 py-2 rounded-lg border bg-transparent
transition-colors duration-micro ease-micro
focus:outline-none focus:ring-2 focus:ring-primary-500
`
const variantClasses = {
default: `
border-border-secondary placeholder:text-text-tertiary
hover:border-border-primary
dark:border-neutral-700 dark:hover:border-neutral-500
`,
error: `
border-error-500 ring-1 ring-error-500
dark:border-error-400 dark:ring-error-400
`,
}
const textareaClasses = computed(() => [
baseClasses,
variantClasses[props.variant],
{ 'opacity-50 cursor-not-allowed': props.disabled }
])
</script>
<style scoped>
.textarea-wrapper {
@apply w-full;
}
.textarea-label {
@apply block text-sm font-medium text-text-primary mb-1;
}
.textarea-description {
@apply text-xs text-text-secondary mt-1;
}
</style>

View File

@ -11,3 +11,5 @@ export { default as Spinner } from './Spinner.vue'
export { default as Alert } from './Alert.vue' export { default as Alert } from './Alert.vue'
export { default as ProgressBar } from './ProgressBar.vue' export { default as ProgressBar } from './ProgressBar.vue'
export { default as Card } from './Card.vue' export { default as Card } from './Card.vue'
export { default as Textarea } from './Textarea.vue'
export { default as Checkbox } from './Checkbox.vue'

View File

@ -1,24 +1,9 @@
import { ref } from 'vue' export function getNextAssignee<T extends { id: number | string }>(members: T[], currentIndex = 0) {
if (members.length === 0) return null
/** const nextIndex = (currentIndex + 1) % members.length
* Round-robin assignment helper for chores or other rotating duties. return members[nextIndex]
* Keeps internal pointer in reactive `index`.
*/
export function useFairness<T>() {
const members = ref<T[]>([])
const index = ref(0)
function setParticipants(list: T[]) {
members.value = list
index.value = 0
} }
function next(): T | undefined { export function useFairness() {
if (members.value.length === 0) return undefined return { getNextAssignee }
const member = members.value[index.value] as unknown as T
index.value = (index.value + 1) % members.value.length
return member
}
return { members, setParticipants, next }
} }

View File

@ -1,34 +1,23 @@
import { onMounted, onUnmounted } from 'vue' import { watch } from 'vue'
import { useOfflineStore } from '@/stores/offline' import { enqueue, flush } from '@/utils/offlineQueue'
import { useSocket } from '@/composables/useSocket'
/** /**
* Hook that wires components into the global offline queue, automatically * Hook that wires components into the global offline queue, automatically
* processing pending mutations when the application regains connectivity. * processing pending mutations when the application regains connectivity.
*/ */
export function useOfflineSync() { export function useOfflineSync() {
const offlineStore = useOfflineStore() const { isConnected } = useSocket()
const handleOnline = () => { watch(isConnected, (online) => {
offlineStore.isOnline = true if (online) {
offlineStore.processQueue() flush(async () => {
// For MVP we just emit back to server via WS.
}).catch((err) => console.error('Offline flush error', err))
} }
const handleOffline = () => { }, { immediate: true })
offlineStore.isOnline = false
}
onMounted(() => {
window.addEventListener('online', handleOnline)
window.addEventListener('offline', handleOffline)
})
onUnmounted(() => {
window.removeEventListener('online', handleOnline)
window.removeEventListener('offline', handleOffline)
})
return { return {
isOnline: offlineStore.isOnline, enqueue,
pendingActions: offlineStore.pendingActions,
processQueue: offlineStore.processQueue,
} }
} }

View File

@ -1,4 +1,4 @@
import { reactive } from 'vue' import { ref } from 'vue'
/** /**
* Generic optimistic-update helper. * Generic optimistic-update helper.
@ -9,25 +9,31 @@ import { reactive } from 'vue'
* await apiCall() * await apiCall()
* confirm(id) // or rollback(id) on failure * confirm(id) // or rollback(id) on failure
*/ */
export function useOptimisticUpdates() {
// Map of rollback callbacks keyed by mutation id
const pending = reactive(new Map<string, () => void>())
function apply(id: string, mutate: () => void, rollback: () => void) { // Generic optimistic updates helper.
if (pending.has(id)) return // Currently a lightweight placeholder that simply applies the updater locally
mutate() // and exposes rollback ability. Replace with robust logic once backend supports PATCH w/ versioning.
pending.set(id, rollback)
type Updater<T> = (draft: T[]) => T[]
export function useOptimisticUpdates<T>(initial: T[]) {
const data = ref<T[]>([...initial] as T[])
const backups: T[][] = []
function mutate(updater: Updater<T>) {
backups.push([...data.value] as T[])
data.value = updater([...data.value] as T[])
} }
function confirm(id: string) { function rollback() {
pending.delete(id) if (backups.length) {
data.value = backups.pop()!
}
} }
function rollback(id: string) { return {
const fn = pending.get(id) data,
if (fn) fn() mutate,
pending.delete(id) rollback,
} }
return { apply, confirm, rollback }
} }

View File

@ -14,6 +14,26 @@ interface NextAction {
priority: number; // 1 = highest priority: number; // 1 = highest
} }
interface PriorityAction {
id: string
type: 'chore' | 'expense' | 'debt' | 'overdue'
priority: 'urgent' | 'high' | 'medium' | 'low'
title: string
description?: string
dueDate?: Date
amount?: number
actionUrl: string
}
interface PersonalStatus {
completedToday: number
netBalance: number
pointsThisWeek: number
streakCount: number
totalTasks: number
pendingExpenses: number
}
export function usePersonalStatus() { export function usePersonalStatus() {
const choreStore = useChoreStore() const choreStore = useChoreStore()
const groupStore = useGroupStore() const groupStore = useGroupStore()
@ -58,6 +78,7 @@ export function usePersonalStatus() {
) )
}) })
// Legacy nextAction for backward compatibility
const nextAction = computed<NextAction>(() => { const nextAction = computed<NextAction>(() => {
const now = new Date() const now = new Date()
// Priority 1: Overdue chores // Priority 1: Overdue chores
@ -76,8 +97,6 @@ export function usePersonalStatus() {
} }
} }
// Placeholder for expense logic - to be implemented
// Priority 2: Upcoming chores // Priority 2: Upcoming chores
const upcomingChoreAssignment = userChoresWithAssignments.value const upcomingChoreAssignment = userChoresWithAssignments.value
.filter(assignment => !assignment.is_complete) .filter(assignment => !assignment.is_complete)
@ -104,8 +123,132 @@ export function usePersonalStatus() {
} }
}) })
// New priority action for the refactored component
const priorityAction = computed<PriorityAction | null>(() => {
const now = new Date()
const userId = authStore.user?.id
if (!userId) return null
// Priority 1: Overdue chores (urgent)
const overdueChoreAssignment = userChoresWithAssignments.value
.filter(assignment => new Date(assignment.due_date) < now && !assignment.is_complete)
.sort((a, b) => new Date(a.due_date).getTime() - new Date(b.due_date).getTime())[0]
if (overdueChoreAssignment) {
return { return {
id: overdueChoreAssignment.id.toString(),
type: 'overdue',
priority: 'urgent',
title: overdueChoreAssignment.choreName,
dueDate: new Date(overdueChoreAssignment.due_date),
actionUrl: '/chores'
}
}
// Priority 2: Due today chores (high)
const dueTodayChoreAssignment = userChoresWithAssignments.value
.filter(assignment => {
const dueDate = new Date(assignment.due_date)
const today = new Date()
return dueDate.toDateString() === today.toDateString() && !assignment.is_complete
})
.sort((a, b) => new Date(a.due_date).getTime() - new Date(b.due_date).getTime())[0]
if (dueTodayChoreAssignment) {
return {
id: dueTodayChoreAssignment.id.toString(),
type: 'chore',
priority: 'high',
title: dueTodayChoreAssignment.choreName,
dueDate: new Date(dueTodayChoreAssignment.due_date),
actionUrl: '/chores'
}
}
// Priority 3: Upcoming chores (medium)
const upcomingChoreAssignment = userChoresWithAssignments.value
.filter(assignment => new Date(assignment.due_date) > now && !assignment.is_complete)
.sort((a, b) => new Date(a.due_date).getTime() - new Date(b.due_date).getTime())[0]
if (upcomingChoreAssignment) {
return {
id: upcomingChoreAssignment.id.toString(),
type: 'chore',
priority: 'medium',
title: upcomingChoreAssignment.choreName,
dueDate: new Date(upcomingChoreAssignment.due_date),
actionUrl: '/chores'
}
}
return null
})
// Personal status summary
const personalStatus = computed<PersonalStatus>(() => {
const userId = authStore.user?.id
if (!userId) {
return {
completedToday: 0,
netBalance: 0,
pointsThisWeek: 0,
streakCount: 0,
totalTasks: 0,
pendingExpenses: 0
}
}
const today = new Date()
const weekStart = new Date(today.getFullYear(), today.getMonth(), today.getDate() - today.getDay())
// Count completed chores today
const completedToday = userChoresWithAssignments.value.filter(assignment => {
if (!assignment.is_complete || !assignment.completed_at) return false
const completedDate = new Date(assignment.completed_at)
return completedDate.toDateString() === today.toDateString()
}).length
// Calculate points this week (assuming 10 points per completed chore)
const pointsThisWeek = userChoresWithAssignments.value.filter(assignment => {
if (!assignment.is_complete || !assignment.completed_at) return false
const completedDate = new Date(assignment.completed_at)
return completedDate >= weekStart
}).length * 10
// Simple streak calculation (consecutive days with completed chores)
let streakCount = 0
const checkDate = new Date(today)
while (streakCount < 30) { // Max 30 days lookback
const dayAssignments = userChoresWithAssignments.value.filter(assignment => {
if (!assignment.is_complete || !assignment.completed_at) return false
const completedDate = new Date(assignment.completed_at)
return completedDate.toDateString() === checkDate.toDateString()
})
if (dayAssignments.length > 0) {
streakCount++
checkDate.setDate(checkDate.getDate() - 1)
} else {
break
}
}
return {
completedToday,
netBalance: 0, // TODO: Calculate from expenses
pointsThisWeek,
streakCount,
totalTasks: userChoresWithAssignments.value.length,
pendingExpenses: expenses.value.length
}
})
return {
// Legacy support
nextAction, nextAction,
isLoading, isLoading,
// New interface
personalStatus,
priorityAction
} }
} }

View File

@ -0,0 +1,82 @@
import { ref, shallowRef, onUnmounted } from 'vue'
import { useAuthStore } from '@/stores/auth'
// Simple wrapper around native WebSocket with auto-reconnect & Vue-friendly API.
// In tests we can provide a mock implementation via `mock-socket`.
// TODO: Move to dedicated class if feature set grows.
interface Listener {
(payload: any): void
}
const defaultWsUrl = import.meta.env.VITE_WS_BASE_URL || 'ws://localhost:8000/ws'
let socket: WebSocket | null = null
const listeners = new Map<string, Set<Listener>>()
const isConnected = ref(false)
function connect(): void {
if (socket?.readyState === WebSocket.OPEN || socket?.readyState === WebSocket.CONNECTING) {
return
}
const authStore = useAuthStore()
const token = authStore.accessToken
const urlWithToken = `${defaultWsUrl}?token=${token}`
socket = new WebSocket(urlWithToken)
socket.addEventListener('open', () => {
isConnected.value = true
})
socket.addEventListener('close', () => {
isConnected.value = false
// Auto-reconnect after short delay
setTimeout(connect, 1_000)
})
socket.addEventListener('message', (event) => {
try {
const data = JSON.parse(event.data)
const { event: evt, payload } = data
listeners.get(evt)?.forEach((cb) => cb(payload))
} catch (err) {
console.error('WS message parse error', err)
}
})
}
function emit(event: string, payload: any): void {
if (!socket || socket.readyState !== WebSocket.OPEN) {
console.warn('WebSocket not connected, skipping emit', event)
return
}
socket.send(JSON.stringify({ event, payload }))
}
function on(event: string, cb: Listener): void {
if (!listeners.has(event)) listeners.set(event, new Set())
listeners.get(event)!.add(cb)
}
function off(event: string, cb: Listener): void {
listeners.get(event)?.delete(cb)
}
// Auto-connect immediately so composable is truly a singleton.
connect()
export function useSocket() {
// Provide stable references to the consumer component.
const connected = shallowRef(isConnected)
onUnmounted(() => {
// Consumers may call off if they registered listeners, but we don't force it here.
})
return {
isConnected: connected,
on,
off,
emit,
}
}

View File

@ -66,6 +66,7 @@ export const API_ENDPOINTS = {
MEMBER: (groupId: string, userId: string) => `/groups/${groupId}/members/${userId}`, MEMBER: (groupId: string, userId: string) => `/groups/${groupId}/members/${userId}`,
CREATE_INVITE: (groupId: string) => `/groups/${groupId}/invites`, CREATE_INVITE: (groupId: string) => `/groups/${groupId}/invites`,
GET_ACTIVE_INVITE: (groupId: string) => `/groups/${groupId}/invites`, GET_ACTIVE_INVITE: (groupId: string) => `/groups/${groupId}/invites`,
REVOKE_INVITE: (groupId: string, code: string) => `/groups/${groupId}/invites/${code}`,
LEAVE: (groupId: string) => `/groups/${groupId}/leave`, LEAVE: (groupId: string) => `/groups/${groupId}/leave`,
DELETE: (groupId: string) => `/groups/${groupId}`, DELETE: (groupId: string) => `/groups/${groupId}`,
SETTINGS: (groupId: string) => `/groups/${groupId}/settings`, SETTINGS: (groupId: string) => `/groups/${groupId}/settings`,
@ -126,6 +127,8 @@ export const API_ENDPOINTS = {
REPORT: (id: string) => `/financials/reports/${id}`, REPORT: (id: string) => `/financials/reports/${id}`,
CATEGORIES: '/financials/categories', CATEGORIES: '/financials/categories',
CATEGORY: (id: string) => `/financials/categories/${id}`, CATEGORY: (id: string) => `/financials/categories/${id}`,
USER_SUMMARY: '/financials/summary/user',
GROUP_SUMMARY: (groupId: string) => `/financials/summary/group/${groupId}`,
}, },
// Health // Health

View File

@ -1,24 +1,212 @@
<template> <template>
<div class="p-4 space-y-4"> <div class="dashboard-page">
<h1 class="text-2xl font-bold">Dashboard</h1> <!-- Header -->
<PersonalStatusCard /> <div class="dashboard-header">
<QuickChoreAdd /> <div class="header-content">
<h1 class="page-title">Dashboard</h1>
<div class="header-actions">
<!-- Quick action shortcuts could go here -->
</div>
</div>
</div>
<!-- Main Content - Single source of truth layout -->
<div class="dashboard-content">
<!-- Personal Status (Above fold) -->
<section class="status-section">
<PersonalStatusCard @priority-action="handlePriorityAction" @create-action="handleCreateAction" />
</section>
<!-- Activity Feed (Social proof) -->
<section class="activity-section">
<div class="section-header">
<h2 class="section-title">Recent Activity</h2>
<Button variant="ghost" color="neutral" size="sm">
View All
</Button>
</div>
<ActivityFeed /> <ActivityFeed />
<UniversalFAB /> </section>
<!-- Quick Actions Section (Contextual) -->
<section class="quick-actions-section">
<div class="section-header">
<h2 class="section-title">Quick Actions</h2>
</div>
<div class="quick-actions-grid">
<QuickChoreAdd />
<!-- Additional quick action cards could be added here -->
</div>
</section>
</div>
<!-- Universal FAB (Fixed positioning) -->
<UniversalFAB @scan-receipt="handleScanReceipt" @create-list="handleCreateList"
@invite-member="handleInviteMember" />
<!-- Notification Area -->
<NotificationDisplay />
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { onMounted } from 'vue'; import { onMounted } from 'vue'
import { useGroupStore } from '@/stores/groupStore'; import { useRouter } from 'vue-router'
import PersonalStatusCard from '@/components/dashboard/PersonalStatusCard.vue'; import { useGroupStore } from '@/stores/groupStore'
import ActivityFeed from '@/components/dashboard/ActivityFeed.vue'; import { useNotificationStore } from '@/stores/notifications'
import UniversalFAB from '@/components/dashboard/UniversalFAB.vue'; import PersonalStatusCard from '@/components/dashboard/PersonalStatusCard.vue'
import QuickChoreAdd from '@/components/QuickChoreAdd.vue'; import ActivityFeed from '@/components/dashboard/ActivityFeed.vue'
import UniversalFAB from '@/components/dashboard/UniversalFAB.vue'
import QuickChoreAdd from '@/components/QuickChoreAdd.vue'
import NotificationDisplay from '@/components/global/NotificationDisplay.vue'
import Button from '@/components/ui/Button.vue'
const groupStore = useGroupStore(); const router = useRouter()
const groupStore = useGroupStore()
const notificationStore = useNotificationStore()
onMounted(() => { onMounted(async () => {
groupStore.fetchUserGroups(); // Fetch user groups and other dashboard data
}); try {
await groupStore.fetchUserGroups()
} catch (error) {
console.error('Failed to load dashboard data:', error)
}
})
// Handle priority action from PersonalStatusCard
const handlePriorityAction = (action: any) => {
// Navigate to the appropriate page based on action type
router.push(action.actionUrl)
notificationStore.addNotification({
type: 'info',
message: `Navigating to ${action.title}`,
})
}
// Handle create action from PersonalStatusCard
const handleCreateAction = () => {
// For now, navigate to lists page
router.push('/lists')
}
// Handle FAB actions
const handleScanReceipt = () => {
// Open receipt scanning interface
notificationStore.addNotification({
type: 'info',
message: 'Receipt scanner opening...',
})
// TODO: Implement receipt scanning
}
const handleCreateList = () => {
// Open list creation modal
router.push('/lists?create=true')
}
const handleInviteMember = () => {
// Open member invitation modal
notificationStore.addNotification({
type: 'info',
message: 'Invite member feature coming soon!',
})
// TODO: Implement member invitation
}
</script> </script>
<style scoped>
.dashboard-page {
@apply min-h-screen bg-neutral-50;
}
.dashboard-header {
@apply bg-white border-b border-neutral-200 sticky top-0 z-20;
@apply shadow-soft;
}
.header-content {
@apply max-w-7xl mx-auto px-4 py-6 flex items-center justify-between;
}
.page-title {
@apply text-2xl font-semibold text-neutral-900;
}
.header-actions {
@apply flex items-center gap-3;
}
.dashboard-content {
@apply max-w-4xl mx-auto px-4 py-6 space-y-8;
/* Ensure content is above FAB on mobile */
@apply pb-32 lg:pb-8;
}
.status-section {
/* Above fold - most important */
@apply space-y-4;
}
.activity-section {
@apply space-y-4;
}
.quick-actions-section {
@apply space-y-4;
}
.section-header {
@apply flex items-center justify-between;
}
.section-title {
@apply text-lg font-medium text-neutral-900;
}
.quick-actions-grid {
@apply grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4;
}
/* Responsive adjustments */
@media (max-width: 640px) {
.header-content {
@apply px-4 py-4;
}
.page-title {
@apply text-xl;
}
.dashboard-content {
@apply px-4 py-4 space-y-6;
}
.section-title {
@apply text-base;
}
}
/* Smooth transitions */
.dashboard-content>section {
animation: fade-in 300ms ease-out;
}
.dashboard-content>section:nth-child(2) {
animation-delay: 100ms;
}
.dashboard-content>section:nth-child(3) {
animation-delay: 200ms;
}
/* Loading states */
.dashboard-content.loading {
@apply opacity-75;
}
.dashboard-content.loading>section {
@apply animate-skeleton;
}
</style>

View File

@ -1,157 +1,477 @@
<template> <template>
<main class="container mx-auto max-w-3xl p-4"> <div class="household-settings-page">
<Heading :level="1" class="mb-6">{{ group?.name || t('householdSettings.title', 'Household Settings') }} <!-- Page Header -->
</Heading> <header class="page-header">
<div class="header-content">
<div class="header-main">
<BaseIcon name="heroicons:cog-8-tooth" class="header-icon" />
<div class="header-text">
<Heading :level="1">Household Settings</Heading>
<p class="subtitle">Manage your household's profile, members, and rules.</p>
</div>
</div>
</div>
</header>
<!-- Rename household --> <!-- Page Content -->
<section class="mb-8"> <main class="page-content">
<Heading :level="3" class="mb-2">{{ t('householdSettings.rename.title', 'Rename Household') }}</Heading> <div class="settings-grid">
<div class="flex gap-2 w-full max-w-md"> <!-- Left Column: Main Settings -->
<Input v-model="editedName" class="flex-1" <div class="settings-column">
:placeholder="t('householdSettings.rename.placeholder', 'New household name')" /> <!-- Household Profile Card -->
<Button :disabled="savingName || !editedName.trim() || editedName === group?.name" @click="saveName"> <Card class="setting-card">
<Spinner v-if="savingName" size="sm" class="mr-1" /> <template #header>
{{ t('shared.save', 'Save') }} <div class="card-header">
<BaseIcon name="heroicons:home-modern" />
<h3 class="card-title">Household Profile</h3>
</div>
</template>
<div class="space-y-4">
<Input v-model="editedName" label="Household Name"
:placeholder="group?.name || 'Your Household Name'" />
<Button @click="saveName" :loading="savingName" :disabled="!isNameChanged">
Save Name
</Button>
<Alert v-if="nameError" type="error" :message="nameError" class="mt-2" />
</div>
</Card>
<!-- Theme Section -->
<Card class="setting-card">
<template #header>
<div class="card-header">
<BaseIcon name="heroicons:paint-brush" />
<h3 class="card-title">Theme</h3>
</div>
</template>
<p class="card-description">Choose a primary color for your household.</p>
<div class="flex flex-wrap gap-3">
<button v-for="c in themeOptions" :key="c" :style="{ backgroundColor: c }"
class="w-8 h-8 rounded-full border-2 flex items-center justify-center"
:class="{ 'ring-2 ring-primary-500': selectedTheme === c }" @click="selectTheme(c)" />
</div>
<div class="mt-4 flex justify-end">
<Button @click="saveTheme" :loading="savingTheme" :disabled="!themeChanged">
Save Theme
</Button> </Button>
</div> </div>
<Alert v-if="nameError" type="error" :message="nameError" class="mt-2" /> </Card>
</section>
<!-- Members list --> <!-- House Rules Card -->
<section class="mb-8"> <Card class="setting-card">
<Heading :level="3" class="mb-4">{{ t('householdSettings.members.title', 'Members') }}</Heading> <template #header>
<div class="card-header">
<BaseIcon name="heroicons:book-open" />
<h3 class="card-title">House Rules</h3>
</div>
</template>
<p class="card-description">Set clear expectations for everyone in the household.</p>
<Textarea v-model="houseRules" placeholder="e.g., &quot;Clean up the kitchen after use.&quot;"
rows="5" />
<div class="mt-4 flex justify-end">
<Button @click="saveRules" :loading="savingRules">
Save Rules
</Button>
</div>
</Card>
<!-- Danger Zone -->
<Card class="setting-card" variant="soft" color="error">
<template #header>
<div class="card-header-error">
<BaseIcon name="heroicons:exclamation-triangle" />
<h3 class="card-title-error">Danger Zone</h3>
</div>
</template>
<div class="space-y-4">
<div class="danger-action">
<div>
<h4 class="font-semibold">Leave Household</h4>
<p class="text-sm text-error-700 dark:text-error-300">You will lose access to all
shared data.</p>
</div>
<Button variant="outline" color="error" @click="confirmLeaveHousehold">Leave</Button>
</div>
<div class="danger-action">
<div>
<h4 class="font-semibold">Delete Household</h4>
<p class="text-sm text-error-700 dark:text-error-300">This will permanently delete
the household and all
its data for everyone. This action cannot be undone.</p>
</div>
<Button variant="solid" color="error" @click="confirmDeleteHousehold">Delete</Button>
</div>
</div>
</Card>
</div>
<!-- Right Column: Members & Invites -->
<div class="settings-column">
<!-- Manage Members Card -->
<Card class="setting-card">
<template #header>
<div class="card-header">
<BaseIcon name="heroicons:users" />
<h3 class="card-title">Manage Members</h3>
</div>
</template>
<ul v-if="group" class="space-y-3"> <ul v-if="group" class="space-y-3">
<li v-for="member in group.members" :key="member.id" <li v-for="member in group.members" :key="member.id" class="member-item">
class="flex items-center justify-between bg-neutral-50 dark:bg-neutral-800 p-3 rounded-md"> <div class="member-info">
<span>{{ member.email }}</span> <div class="member-avatar">{{ member.email.charAt(0).toUpperCase() }}</div>
<Button v-if="canRemove(member)" variant="ghost" color="danger" size="sm" <div class="member-details">
<span class="member-email">{{ member.email }}</span>
<span class="member-role">{{ member.role }}</span>
</div>
</div>
<Button v-if="canRemove(member)" variant="ghost" color="error" size="sm"
@click="confirmRemove(member)"> @click="confirmRemove(member)">
{{ t('householdSettings.members.remove', 'Remove') }} Remove
</Button> </Button>
</li> </li>
</ul> </ul>
<Alert v-else type="info" :message="t('householdSettings.members.loading', 'Loading members...')" /> <Alert v-else type="info" message="Loading members..." />
</section> </Card>
<!-- Invite manager --> <!-- Invite Manager Card -->
<Card class="setting-card">
<template #header>
<div class="card-header">
<BaseIcon name="heroicons:envelope-open" />
<h3 class="card-title">Invite New Members</h3>
</div>
</template>
<InviteManager v-if="group" :group-id="group.id" /> <InviteManager v-if="group" :group-id="group.id" />
</Card>
</div>
</div>
</main>
<!-- Remove member confirm dialog --> <!-- Confirmation Dialog -->
<Dialog v-model="showRemoveDialog"> <Dialog v-model="showConfirmDialog">
<div class="space-y-4"> <div class="space-y-4">
<Heading :level="3">{{ t('householdSettings.members.confirmTitle', 'Remove member?') }}</Heading> <Heading :level="3">{{ confirmDialog.title }}</Heading>
<p>{{ confirmPrompt }}</p> <p>{{ confirmDialog.message }}</p>
<div class="flex justify-end gap-2"> <div class="flex justify-end gap-2">
<Button variant="ghost" color="neutral" @click="showRemoveDialog = false">{{ t('shared.cancel', <Button variant="ghost" color="neutral" @click="showConfirmDialog = false">Cancel</Button>
'Cancel') }}</Button> <Button variant="solid" :color="confirmDialog.confirmColor" :loading="isConfirming"
<Button variant="solid" color="danger" :disabled="removing" @click="removeConfirmed"> @click="executeConfirmAction">
<Spinner v-if="removing" size="sm" class="mr-1" /> {{ confirmDialog.confirmText }}
{{ t('shared.remove', 'Remove') }}
</Button> </Button>
</div> </div>
</div> </div>
</Dialog> </Dialog>
</main> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted, computed } from 'vue' import { ref, onMounted, computed } from 'vue'
import { useRoute } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { apiClient, API_ENDPOINTS } from '@/services/api' import { apiClient, API_ENDPOINTS } from '@/services/api'
import Heading from '@/components/ui/Heading.vue' import { useAuthStore } from '@/stores/auth'
import Input from '@/components/ui/Input.vue' import { Heading, Input, Button, Alert, Dialog, Card, Textarea } from '@/components/ui'
import Button from '@/components/ui/Button.vue' import BaseIcon from '@/components/BaseIcon.vue'
import Alert from '@/components/ui/Alert.vue'
import Spinner from '@/components/ui/Spinner.vue'
import Dialog from '@/components/ui/Dialog.vue'
import InviteManager from '@/components/InviteManager.vue' import InviteManager from '@/components/InviteManager.vue'
import { useNotificationStore } from '@/stores/notifications'
interface Member { interface Member {
id: number id: number
email: string email: string
role?: string role: 'owner' | 'admin' | 'member'
} }
interface Group { interface Group {
id: number id: number
name: string name: string
members: Member[] members: Member[]
rules?: string
meta?: {
theme_color?: string
[key: string]: unknown
}
} }
const route = useRoute() const route = useRoute()
const router = useRouter()
const { t } = useI18n() const { t } = useI18n()
const auth = useAuthStore()
const notifications = useNotificationStore()
const groupId = Number(route.params.id || route.params.groupId) const groupId = Number(route.params.id || route.params.groupId)
const group = ref<Group | null>(null) const group = ref<Group | null>(null)
const loading = ref(true) const loading = ref(true)
const loadError = ref<string | null>(null)
// Edit name state
const editedName = ref('') const editedName = ref('')
const savingName = ref(false) const savingName = ref(false)
const nameError = ref<string | null>(null) const nameError = ref<string | null>(null)
const isNameChanged = computed(() => group.value && editedName.value.trim() && editedName.value.trim() !== group.value.name)
const showRemoveDialog = ref(false) // House rules state
const memberToRemove = ref<Member | null>(null) const houseRules = ref('')
const removing = ref(false) const savingRules = ref(false)
const confirmPrompt = computed(() => { // Theme state
const email = memberToRemove.value?.email ?? '' const themeOptions = ['#ff5757', '#ffb01f', '#00d084', '#1e90ff', '#9333ea']
return t('householdSettings.members.confirmMessage', { email }, `Are you sure you want to remove ${email} from the household?`) const selectedTheme = ref<string>('')
const originalTheme = ref<string>('')
const savingTheme = ref(false)
const themeChanged = computed(() => selectedTheme.value && selectedTheme.value !== originalTheme.value)
// Confirmation Dialog State
const showConfirmDialog = ref(false)
const isConfirming = ref(false)
const confirmDialog = ref({
title: '',
message: '',
confirmText: '',
confirmColor: 'primary' as 'primary' | 'error',
action: () => { }
}) })
function canRemove(member: Member): boolean { function canRemove(member: Member): boolean {
return member.role !== 'owner' // Cannot remove self, cannot remove owner unless you are an owner
if (member.id === auth.user?.id) return false
const currentUser = group.value?.members.find(m => m.id === auth.user?.id)
if (currentUser?.role !== 'owner' && member.role === 'owner') return false
return true
} }
async function fetchGroup() { async function fetchGroup() {
loading.value = true loading.value = true
loadError.value = null
try { try {
const res = await apiClient.get(API_ENDPOINTS.GROUPS.BY_ID(String(groupId))) const { data } = await apiClient.get(API_ENDPOINTS.GROUPS.BY_ID(String(groupId)))
group.value = res.data as Group group.value = data
editedName.value = group.value.name editedName.value = data.name
houseRules.value = data.rules || ''
originalTheme.value = data.meta?.theme_color || themeOptions[0]
selectedTheme.value = originalTheme.value
} catch (err: any) { } catch (err: any) {
loadError.value = err?.response?.data?.detail || err.message || t('householdSettings.loadError', 'Failed to load household info') notifications.addNotification({ type: 'error', message: 'Failed to load household info.' })
router.push('/')
} finally { } finally {
loading.value = false loading.value = false
} }
} }
async function saveName() { async function saveName() {
if (!group.value || editedName.value.trim() === group.value.name) return if (!isNameChanged.value) return
savingName.value = true savingName.value = true
nameError.value = null nameError.value = null
try { try {
const res = await apiClient.patch(API_ENDPOINTS.GROUPS.BY_ID(String(groupId)), { name: editedName.value.trim() }) const { data } = await apiClient.patch(API_ENDPOINTS.GROUPS.BY_ID(String(groupId)), { name: editedName.value.trim() })
group.value = { ...group.value, name: res.data.name } if (group.value) {
group.value.name = data.name
}
notifications.addNotification({ type: 'success', message: 'Household name updated!' })
} catch (err: any) { } catch (err: any) {
nameError.value = err?.response?.data?.detail || err.message || t('householdSettings.rename.error', 'Failed to rename household') nameError.value = err?.response?.data?.detail || 'Failed to rename household'
} finally { } finally {
savingName.value = false savingName.value = false
} }
} }
function confirmRemove(member: Member) { async function saveRules() {
memberToRemove.value = member savingRules.value = true
showRemoveDialog.value = true try {
await apiClient.patch(API_ENDPOINTS.GROUPS.BY_ID(String(groupId)), { rules: houseRules.value })
if (group.value) {
group.value.rules = houseRules.value
}
notifications.addNotification({ type: 'success', message: 'House rules saved.' })
} catch (err) {
notifications.addNotification({ type: 'error', message: 'Failed to save rules.' })
} finally {
savingRules.value = false
}
} }
async function removeConfirmed() { async function saveTheme() {
if (!group.value || !memberToRemove.value) return savingTheme.value = true
removing.value = true
try { try {
await apiClient.delete(API_ENDPOINTS.GROUPS.MEMBER(String(groupId), String(memberToRemove.value.id))) await apiClient.patch(API_ENDPOINTS.GROUPS.SETTINGS(String(groupId)), { theme_color: selectedTheme.value })
group.value.members = group.value.members.filter(m => m.id !== memberToRemove.value!.id) originalTheme.value = selectedTheme.value
showRemoveDialog.value = false notifications.addNotification({ type: 'success', message: 'House theme updated.' })
} catch (err: any) { } catch (err) {
// show error alert inside dialog maybe notifications.addNotification({ type: 'error', message: 'Failed to save theme.' })
} finally { } finally {
removing.value = false savingTheme.value = false
} }
} }
function confirmRemove(member: Member) {
showConfirmDialog.value = true
confirmDialog.value = {
title: 'Remove Member?',
message: `Are you sure you want to remove ${member.email} from the household? They will lose access to all shared data.`,
confirmText: 'Yes, Remove',
confirmColor: 'error',
action: () => removeMember(member.id)
}
}
async function removeMember(memberId: number) {
if (!group.value) return
isConfirming.value = true
try {
await apiClient.delete(API_ENDPOINTS.GROUPS.MEMBER(String(groupId), String(memberId)))
group.value.members = group.value.members.filter(m => m.id !== memberId)
notifications.addNotification({ type: 'success', message: 'Member removed.' })
showConfirmDialog.value = false
} catch (err: any) {
notifications.addNotification({ type: 'error', message: 'Failed to remove member.' })
} finally {
isConfirming.value = false
}
}
function confirmLeaveHousehold() {
showConfirmDialog.value = true
confirmDialog.value = {
title: 'Leave Household?',
message: 'Are you sure you want to leave this household? This action cannot be undone.',
confirmText: 'Yes, Leave',
confirmColor: 'error',
action: leaveHousehold
}
}
async function leaveHousehold() {
isConfirming.value = true
try {
await apiClient.post(API_ENDPOINTS.GROUPS.LEAVE(String(groupId)))
notifications.addNotification({ type: 'success', message: "You have left the household." })
router.push('/')
} catch (err) {
notifications.addNotification({ type: 'error', message: "Failed to leave household." })
} finally {
isConfirming.value = false
showConfirmDialog.value = false
}
}
function confirmDeleteHousehold() {
showConfirmDialog.value = true
confirmDialog.value = {
title: 'Delete Household?',
message: 'This will permanently delete the household and all associated data for all members. This action cannot be undone. Are you absolutely sure?',
confirmText: 'Yes, Delete Forever',
confirmColor: 'error',
action: deleteHousehold
}
}
async function deleteHousehold() {
isConfirming.value = true
try {
await apiClient.delete(API_ENDPOINTS.GROUPS.BY_ID(String(groupId)))
notifications.addNotification({ type: 'success', message: "Household has been deleted." })
router.push('/')
} catch (err) {
notifications.addNotification({ type: 'error', message: "Failed to delete household." })
} finally {
isConfirming.value = false
showConfirmDialog.value = false
}
}
function executeConfirmAction() {
confirmDialog.value.action()
}
function selectTheme(c: string) {
selectedTheme.value = c
}
onMounted(fetchGroup) onMounted(fetchGroup)
</script> </script>
<style scoped></style> <style scoped>
.household-settings-page {
@apply max-w-7xl mx-auto py-8 px-4 sm:px-6 lg:px-8;
}
/* Header */
.page-header {
@apply pb-8 border-b border-border-primary mb-8;
}
.header-content {
@apply flex items-center gap-4;
}
.header-icon {
@apply w-12 h-12 text-primary-500;
}
.subtitle {
@apply text-lg text-text-secondary mt-1;
}
/* Content Grid */
.settings-grid {
@apply grid grid-cols-1 lg:grid-cols-3 gap-8;
}
.settings-column:first-child {
@apply lg:col-span-2 space-y-8;
}
.settings-column:last-child {
@apply space-y-8;
}
/* Cards */
.setting-card {
@apply w-full;
}
.card-header {
@apply flex items-center gap-3 text-lg font-semibold text-text-primary;
}
.card-description {
@apply text-sm text-text-secondary mb-4 -mt-2;
}
/* Member List */
.member-item {
@apply flex items-center justify-between p-3 bg-surface-soft rounded-lg;
}
.member-info {
@apply flex items-center gap-3;
}
.member-avatar {
@apply w-10 h-10 bg-primary-100 text-primary-600 dark:bg-primary-900/50 dark:text-primary-300;
@apply rounded-full flex items-center justify-center font-bold text-lg;
}
.member-details {
@apply flex flex-col;
}
.member-email {
@apply font-medium text-text-primary;
}
.member-role {
@apply text-sm text-text-secondary capitalize;
}
/* Danger Zone */
.card-header-error {
@apply flex items-center gap-3 text-lg font-semibold text-error-700 dark:text-error-300;
}
.card-title-error {
@apply text-error-800 dark:text-error-200;
}
.danger-action {
@apply flex items-center justify-between p-4 rounded-lg;
@apply bg-error-100/50 dark:bg-error-900/20;
}
</style>

View File

@ -37,7 +37,7 @@
Email Magic Link Email Magic Link
</button> </button>
<AuthenticationSheet ref="sheet" /> <AuthenticationSheet v-model="isSheetOpen" ref="sheet" />
<div class="text-center mt-2"> <div class="text-center mt-2">
<router-link to="/auth/signup" class="link-styled">{{ t('loginPage.signupLink') }}</router-link> <router-link to="/auth/signup" class="link-styled">{{ t('loginPage.signupLink') }}</router-link>
@ -70,11 +70,12 @@ const email = ref('');
const password = ref(''); const password = ref('');
const isPwdVisible = ref(false); const isPwdVisible = ref(false);
const loading = ref(false); const loading = ref(false);
const isSheetOpen = ref(false);
const formErrors = ref<{ email?: string; password?: string; general?: string }>({}); const formErrors = ref<{ email?: string; password?: string; general?: string }>({});
const sheet = ref<InstanceType<typeof AuthenticationSheet>>() const sheet = ref<InstanceType<typeof AuthenticationSheet>>()
function openSheet() { function openSheet() {
sheet.value?.show() isSheetOpen.value = true;
} }
const isValidEmail = (val: string): boolean => { const isValidEmail = (val: string): boolean => {

View File

@ -17,107 +17,216 @@ import {
createHandlerBoundToURL, createHandlerBoundToURL,
} from 'workbox-precaching'; } from 'workbox-precaching';
import { registerRoute, NavigationRoute } from 'workbox-routing'; import { registerRoute, NavigationRoute } from 'workbox-routing';
import { CacheFirst, NetworkFirst } from 'workbox-strategies'; import { CacheFirst, NetworkFirst, StaleWhileRevalidate } from 'workbox-strategies';
import { ExpirationPlugin } from 'workbox-expiration'; import { ExpirationPlugin } from 'workbox-expiration';
import { CacheableResponsePlugin } from 'workbox-cacheable-response'; import { CacheableResponsePlugin } from 'workbox-cacheable-response';
import { BackgroundSyncPlugin } from 'workbox-background-sync'; import { BackgroundSyncPlugin } from 'workbox-background-sync';
import type { WorkboxPlugin } from 'workbox-core/types'; import type { WorkboxPlugin } from 'workbox-core/types';
// Create a background sync plugin instance // Precache all assets generated by Vite
const bgSyncPlugin = new BackgroundSyncPlugin('offline-actions-queue', {
maxRetentionTime: 24 * 60, // Retry for max of 24 Hours (specified in minutes)
});
// Initialize service worker
const initializeSW = async () => {
try {
await self.skipWaiting();
clientsClaim();
// console.log('Service Worker initialized successfully');
} catch (error) {
console.error('Error during service worker initialization:', error);
}
};
// Use with precache injection
// vite-plugin-pwa will populate self.__WB_MANIFEST
precacheAndRoute(self.__WB_MANIFEST); precacheAndRoute(self.__WB_MANIFEST);
// Clean up outdated caches
cleanupOutdatedCaches(); cleanupOutdatedCaches();
// Cache app shell and static assets with Cache First strategy // Take control of all pages immediately
clientsClaim();
// Background sync plugin
const bgSyncPlugin = new BackgroundSyncPlugin('api-queue', {
maxRetentionTime: 24 * 60, // Retry for 24 hours
});
// Cache API responses with NetworkFirst strategy
registerRoute(
({ url }) => url.pathname.startsWith('/api/'),
new NetworkFirst({
cacheName: 'api-cache',
networkTimeoutSeconds: 3,
plugins: [
{
cacheKeyWillBeUsed: async ({ request }) => {
// Create cache keys that ignore auth headers for better cache hits
const url = new URL(request.url);
return url.pathname + url.search;
},
},
],
})
);
// Cache static assets with CacheFirst strategy
registerRoute( registerRoute(
({ request }) => ({ request }) =>
request.destination === 'style' ||
request.destination === 'script' ||
request.destination === 'image' || request.destination === 'image' ||
request.destination === 'font', request.destination === 'font' ||
request.destination === 'style',
new CacheFirst({ new CacheFirst({
cacheName: 'static-assets', cacheName: 'static-assets',
plugins: [ plugins: [
new CacheableResponsePlugin({ {
statuses: [0, 200], cacheWillUpdate: async ({ response }) => {
}) as WorkboxPlugin, return response.status === 200 ? response : null;
new ExpirationPlugin({ },
maxEntries: 60, },
maxAgeSeconds: 30 * 24 * 60 * 60, // 30 days
}) as WorkboxPlugin,
], ],
}) })
); );
// Cache API calls with Network First strategy and Background Sync for failed requests // Cache navigation requests with StaleWhileRevalidate
registerRoute(
({ url }) => url.pathname.startsWith('/api/'), // Make sure this matches your actual API path structure
new NetworkFirst({
cacheName: 'api-cache',
plugins: [
new CacheableResponsePlugin({
statuses: [0, 200],
}) as WorkboxPlugin,
new ExpirationPlugin({
maxEntries: 50,
maxAgeSeconds: 24 * 60 * 60, // 24 hours
}) as WorkboxPlugin,
bgSyncPlugin, // Add background sync plugin for failed requests
],
})
);
// Non-SSR fallbacks to index.html
// Production SSR fallbacks to offline.html (except for dev)
// Using environment variables defined in vite.config.ts and injected by Vite
declare const __PWA_FALLBACK_HTML__: string;
declare const __PWA_SERVICE_WORKER_REGEX__: string;
// Use fallback values if not defined
const PWA_FALLBACK_HTML = typeof __PWA_FALLBACK_HTML__ !== 'undefined' ? __PWA_FALLBACK_HTML__ : '/index.html';
const PWA_SERVICE_WORKER_REGEX = typeof __PWA_SERVICE_WORKER_REGEX__ !== 'undefined'
? new RegExp(__PWA_SERVICE_WORKER_REGEX__)
: /^(sw|workbox)-.*\.js$/;
// Register navigation route for SPA fallback
if (import.meta.env.MODE !== 'ssr' || import.meta.env.PROD) {
// Cache the index.html explicitly for navigation fallback
registerRoute( registerRoute(
({ request }) => request.mode === 'navigate', ({ request }) => request.mode === 'navigate',
new NetworkFirst({ new StaleWhileRevalidate({
cacheName: 'navigation-cache', cacheName: 'navigation-cache',
plugins: [
new CacheableResponsePlugin({
statuses: [0, 200],
}) as WorkboxPlugin,
],
}) })
); );
// Register fallback for offline navigation // Register background sync route for API calls
registerRoute( registerRoute(
new NavigationRoute(createHandlerBoundToURL(PWA_FALLBACK_HTML), { ({ url }) => url.pathname.startsWith('/api/') &&
denylist: [PWA_SERVICE_WORKER_REGEX, /workbox-(.)*\.js$/], !url.pathname.includes('/auth/'),
new NetworkFirst({
cacheName: 'api-mutations',
plugins: [bgSyncPlugin],
}), }),
'POST'
);
registerRoute(
({ url }) => url.pathname.startsWith('/api/') &&
!url.pathname.includes('/auth/'),
new NetworkFirst({
cacheName: 'api-mutations',
plugins: [bgSyncPlugin],
}),
'PUT'
);
registerRoute(
({ url }) => url.pathname.startsWith('/api/') &&
!url.pathname.includes('/auth/'),
new NetworkFirst({
cacheName: 'api-mutations',
plugins: [bgSyncPlugin],
}),
'DELETE'
);
// Handle offline fallback
const OFFLINE_FALLBACK_URL = '/offline.html';
// Cache the offline fallback page
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open('offline-fallback')
.then(cache => cache.add(OFFLINE_FALLBACK_URL))
);
});
// Serve offline fallback when network fails
self.addEventListener('fetch', (event) => {
if (event.request.mode === 'navigate') {
event.respondWith(
(async () => {
try {
return await fetch(event.request);
} catch {
const cached = await caches.match(OFFLINE_FALLBACK_URL);
return cached ?? new Response('Offline', { status: 503, statusText: 'Offline' });
}
})()
); );
} }
});
// Initialize the service worker // Listen for messages from main thread
initializeSW(); self.addEventListener('message', (event) => {
if (event.data?.type === 'SYNC_BACKGROUND') {
// Trigger background sync
self.registration.sync.register('background-sync');
}
if (event.data?.type === 'CLEAR_CACHE') {
// Clear specific cache
const cacheName = event.data.cacheName;
caches.delete(cacheName);
}
if (event.data?.type === 'SKIP_WAITING') {
self.skipWaiting();
}
});
// Background sync event
self.addEventListener('sync', (event) => {
if (event.tag === 'background-sync') {
console.log('Background sync triggered');
}
});
// Show notification when app is updated
self.addEventListener('controllerchange', () => {
// Notify main thread that SW has updated
self.clients.matchAll().then(clients => {
clients.forEach(client => {
client.postMessage({
type: 'SW_UPDATED',
message: 'App updated! Refresh to see changes.'
});
});
});
});
// Progressive enhancement: add install prompt handling
let deferredPrompt: any;
self.addEventListener('beforeinstallprompt', (e) => {
e.preventDefault();
deferredPrompt = e;
// Notify main thread that install is available
self.clients.matchAll().then(clients => {
clients.forEach(client => {
client.postMessage({
type: 'INSTALL_AVAILABLE',
message: 'App can be installed'
});
});
});
});
// Handle install event
self.addEventListener('install', (event) => {
console.log('Service Worker installing...');
// Cache critical resources immediately
event.waitUntil(
caches.open('critical-cache-v1').then(cache => {
return cache.addAll([
'/',
'/manifest.json',
'/offline.html'
]);
})
);
});
// Handle activation
self.addEventListener('activate', (event) => {
console.log('Service Worker activated');
// Clean up old caches
event.waitUntil(
caches.keys().then(cacheNames => {
return Promise.all(
cacheNames
.filter(cacheName => cacheName.startsWith('critical-cache-') &&
cacheName !== 'critical-cache-v1')
.map(cacheName => caches.delete(cacheName))
);
})
);
});
// Export for TypeScript
export default null;

View File

@ -5,11 +5,14 @@ export interface List {
id: number id: number
name: string name: string
description?: string | null description?: string | null
type: 'shopping' | 'todo' | 'custom'
is_complete: boolean is_complete: boolean
group_id?: number | null group_id?: number | null
items: Item[] items: Item[]
version: number version: number
updated_at: string updated_at: string
created_at: string
archived_at?: string | null
expenses?: Expense[] expenses?: Expense[]
} }

15
fe/src/utils/analytics.ts Normal file
View File

@ -0,0 +1,15 @@
export type AnalyticsPayload = Record<string, unknown>
/**
* Lightweight analytics stub. In production this should forward to a real
* provider (PostHog, Plausible, etc.). For now we just log for visibility and
* keep a typed API so that calls compile even when the real service is absent.
*/
export function track(event: string, payload: AnalyticsPayload = {}) {
if (import.meta.env.MODE === 'production') {
// TODO: connect real analytics provider
} else {
// eslint-disable-next-line no-console
console.debug(`[analytics] ${event}`, payload)
}
}

View File

@ -0,0 +1,45 @@
import { get, set, del } from 'idb-keyval'
export type OfflineOperation = {
id: string
type: string
payload: any
timestamp: number
}
const STORE_KEY = 'offline-queue'
async function readAll(): Promise<OfflineOperation[]> {
const data = await get<OfflineOperation[]>(STORE_KEY)
return data || []
}
export async function enqueue(op: Omit<OfflineOperation, 'id' | 'timestamp'>) {
const current = await readAll()
current.push({ ...op, id: crypto.randomUUID(), timestamp: Date.now() })
await set(STORE_KEY, current)
}
export async function flush(handler: (op: OfflineOperation) => Promise<void>) {
const queue = await readAll()
for (const op of queue) {
try {
await handler(op)
await remove(op.id)
} catch (err) {
console.error('Failed to process offline op', op, err)
// stop flush on first failure to avoid loop
break
}
}
}
export async function remove(id: string) {
const current = await readAll()
const updated = current.filter((o) => o.id !== id)
await set(STORE_KEY, updated)
}
export async function clear() {
await del(STORE_KEY)
}

View File

@ -13,29 +13,225 @@ const config: Config = {
theme: { theme: {
extend: { extend: {
colors: { colors: {
primary: '#FF7B54', // Design System Colors from refactor plan
primary: {
DEFAULT: '#3b82f6',
50: '#eff6ff',
100: '#dbeafe',
200: '#bfdbfe',
300: '#93c5fd',
400: '#60a5fa',
500: '#3b82f6', // Main primary color
600: '#2563eb',
700: '#1d4ed8',
800: '#1e40af',
900: '#1e3a8a',
950: '#172554',
},
success: {
50: '#ecfdf5',
100: '#d1fae5',
200: '#a7f3d0',
300: '#6ee7b7',
400: '#34d399',
500: '#10b981', // Main success color
600: '#059669',
700: '#047857',
800: '#065f46',
900: '#064e3b',
950: '#022c22',
},
warning: {
50: '#fffbeb',
100: '#fef3c7',
200: '#fde68a',
300: '#fcd34d',
400: '#fbbf24',
500: '#f59e0b', // Main warning color
600: '#d97706',
700: '#b45309',
800: '#92400e',
900: '#78350f',
950: '#4c2209',
},
error: {
50: '#fef2f2',
100: '#fee2e2',
200: '#fecaca',
300: '#fca5a5',
400: '#f87171',
500: '#ef4444', // Main error color
600: '#dc2626',
700: '#b91c1c',
800: '#991b1b',
900: '#7f1d1d',
950: '#661010',
},
neutral: {
50: '#f8fafc',
100: '#f1f5f9',
200: '#e2e8f0',
300: '#cbd5e1',
400: '#94a3b8',
500: '#64748b', // Main neutral color
600: '#475569',
700: '#334155',
800: '#1e293b',
900: '#0f172a',
},
// Legacy colors for backward compatibility
secondary: '#FFB26B', secondary: '#FFB26B',
accent: '#FFD56B', accent: '#FFD56B',
info: '#54C7FF', info: '#54C7FF',
success: '#A0E7A0',
warning: '#FFD56B',
danger: '#FF4D4D', danger: '#FF4D4D',
dark: '#393E46', dark: '#393E46',
light: '#FFF8F0', light: '#FFF8F0',
black: '#000000', black: '#000000',
neutral: '#64748B', // Additional color aliases for design tokens
'surface-primary': '#ffffff', // Light surface
'surface-secondary': '#f8fafc',
'surface-elevated': '#ffffff', // elevated surface same as primary for now
'surface-hover': '#f1f5f9', // neutral-100, subtle hover for surfaces
'surface-soft': '#f8fafc', // neutral-50, extra soft surface
// Border colors
'border-secondary': '#e2e8f0', // neutral-200
'border-subtle': '#cbd5e1', // neutral-300
// Text colors
'text-primary': '#0f172a', // neutral-900
'text-secondary': '#475569', // neutral-600
'text-tertiary': '#94a3b8', // neutral-400
// Border primary alias (light)
'border-primary': '#bfdbfe', // primary-200
}, },
spacing: { spacing: {
1: '4px', // Design system spacing (base unit: 4px)
4: '16px', '1': '4px',
6: '24px', '2': '8px',
8: '32px', '3': '12px',
12: '48px', '4': '16px', // Component spacing
16: '64px', '5': '20px',
'6': '24px', // Component spacing
'7': '28px',
'8': '32px', // Component spacing
'9': '36px',
'10': '40px',
'11': '44px',
'12': '48px', // Section spacing
'14': '56px',
'16': '64px', // Section spacing
'20': '80px',
'24': '96px',
'28': '112px',
'32': '128px',
// Alias tokens for design system (used as spacing-[size])
'spacing-xs': '4px', // 1
'spacing-sm': '8px', // 2
'spacing-md': '16px', // 4
'spacing-lg': '24px', // 6
'spacing-xl': '32px', // 8
}, },
fontFamily: { fontFamily: {
// Typography system
sans: [
'Inter',
'-apple-system',
'BlinkMacSystemFont',
'San Francisco',
'Segoe UI',
'Roboto',
'Helvetica Neue',
'Arial',
'sans-serif'
],
mono: [
'JetBrains Mono',
'SFMono-Regular',
'Monaco',
'Consolas',
'Liberation Mono',
'Courier New',
'monospace'
],
// Legacy
hand: ['"Patrick Hand"', 'cursive'], hand: ['"Patrick Hand"', 'cursive'],
sans: ['Inter', 'ui-sans-serif', 'system-ui'], },
fontSize: {
'xs': ['0.75rem', { lineHeight: '1rem' }],
'sm': ['0.875rem', { lineHeight: '1.25rem' }],
'base': ['1rem', { lineHeight: '1.5rem' }],
'lg': ['1.125rem', { lineHeight: '1.75rem' }],
'xl': ['1.25rem', { lineHeight: '1.75rem' }],
'2xl': ['1.5rem', { lineHeight: '2rem' }],
'3xl': ['1.875rem', { lineHeight: '2.25rem' }],
'4xl': ['2.25rem', { lineHeight: '2.5rem' }],
'5xl': ['3rem', { lineHeight: '1' }],
'6xl': ['3.75rem', { lineHeight: '1' }],
// Alias font-size tokens for design system
'label-xs': ['0.75rem', { lineHeight: '1rem' }],
'label-sm': ['0.875rem', { lineHeight: '1.25rem' }],
'body-sm': ['0.875rem', { lineHeight: '1.25rem' }],
'heading-lg': ['1.5rem', { lineHeight: '2rem' }],
},
transitionDuration: {
// Motion system
'micro': '150ms', // Micro-interactions
'page': '300ms', // Page transitions
'slow': '500ms', // Loading states
// Medium alias (used in components)
'medium': '300ms',
},
transitionTimingFunction: {
// Custom timing functions for design system
'micro': 'cubic-bezier(0.4, 0, 0.2, 1)', // alias for ease-out micro interactions
'medium': 'cubic-bezier(0.4, 0, 0.2, 1)', // same curve, longer duration
'page': 'cubic-bezier(0.4, 0, 0.6, 1)', // ease-in-out for page transitions
},
keyframes: {
// Skeleton loading animation
skeleton: {
'0%': { opacity: '1' },
'50%': { opacity: '0.4' },
'100%': { opacity: '1' },
},
// Fade in animation
fadeIn: {
'0%': { opacity: '0', transform: 'translateY(10px)' },
'100%': { opacity: '1', transform: 'translateY(0)' },
},
// Scale in animation
scaleIn: {
'0%': { opacity: '0', transform: 'scale(0.95)' },
'100%': { opacity: '1', transform: 'scale(1)' },
},
},
animation: {
'skeleton': 'skeleton 1.5s ease-in-out infinite',
'fade-in': 'fadeIn 300ms ease-out',
'scale-in': 'scaleIn 150ms ease-out',
},
backdropBlur: {
xs: '2px',
},
boxShadow: {
'soft': '0 2px 8px rgba(0, 0, 0, 0.04)',
'medium': '0 4px 12px rgba(0, 0, 0, 0.08)',
'strong': '0 8px 24px rgba(0, 0, 0, 0.12)',
'floating': '0 12px 32px rgba(0, 0, 0, 0.16)',
// Alias box-shadow tokens matching naming convention in components
'elevation-soft': '0 2px 8px rgba(0, 0, 0, 0.04)',
'elevation-medium': '0 4px 12px rgba(0, 0, 0, 0.08)',
'elevation-strong': '0 8px 24px rgba(0, 0, 0, 0.12)',
'elevation-high': '0 16px 48px rgba(0,0,0,0.18)',
},
borderRadius: {
// Radius alias tokens
'radius-sm': '4px',
'radius-md': '8px',
'radius-lg': '12px',
'radius-full': '9999px',
},
zIndex: {
60: '60',
}, },
}, },
}, },

View File

@ -1,12 +1,25 @@
{ {
"extends": "@vue/tsconfig/tsconfig.dom.json", "extends": "@vue/tsconfig/tsconfig.dom.json",
"include": ["env.d.ts", "src/**/*", "src/**/*.vue"], "include": [
"exclude": ["src/**/__tests__/*"], "env.d.ts",
"src/**/*",
"src/**/*.vue"
],
"exclude": [
"src/**/__tests__/*"
],
"compilerOptions": { "compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"paths": { "paths": {
"@/*": ["./src/*"] "@/*": [
} "./src/*"
]
},
"lib": [
"DOM",
"DOM.Iterable",
"ESNext",
"WebWorker"
]
} }
} }

6
package-lock.json generated
View File

@ -1,6 +0,0 @@
{
"name": "doe",
"lockfileVersion": 3,
"requires": true,
"packages": {}
}