Compare commits
9 Commits
9318bc56ac
...
62916a8397
| Author | SHA1 | Date | |
|---|---|---|---|
| 62916a8397 | |||
| 596872d942 | |||
| da5ce7da1d | |||
| 452928760a | |||
| 957d661567 | |||
| e3124e49c9 | |||
| 581872b534 | |||
| ce48121b2b | |||
| 2948cc5848 |
70
.planning/phases/30-onboarding-redesign/30-UAT.md
Normal file
70
.planning/phases/30-onboarding-redesign/30-UAT.md
Normal file
@@ -0,0 +1,70 @@
|
||||
---
|
||||
status: partial
|
||||
phase: 30-onboarding-redesign
|
||||
source: [30-01-SUMMARY.md, 30-02-SUMMARY.md, 30-03-SUMMARY.md]
|
||||
started: 2026-04-12T19:30:00Z
|
||||
updated: 2026-04-12T19:40:00Z
|
||||
---
|
||||
|
||||
## Current Test
|
||||
|
||||
[testing complete]
|
||||
|
||||
## Tests
|
||||
|
||||
### 1. Onboarding triggers on first login
|
||||
expected: After creating a new account and signing in for the first time, a full-screen onboarding flow appears (not the old small modal wizard).
|
||||
result: pass
|
||||
|
||||
### 2. Welcome step
|
||||
expected: First screen shows a welcome message with a "Get Started" button. Full-screen, big visuals, immersive feel.
|
||||
result: pass
|
||||
|
||||
### 3. Hobby picker (required step)
|
||||
expected: Second screen shows hobby cards with icons (Bikepacking, Hiking, Climbing, Cycling, etc.). You can select one or more. This step cannot be skipped.
|
||||
result: issue
|
||||
reported: "Works but selected cards need stronger visual distinction — dark gray fill with inverted text/icon instead of just a border change."
|
||||
severity: cosmetic
|
||||
|
||||
### 4. Item browser
|
||||
expected: After picking a hobby, you see a grid of popular catalog items filtered by that hobby.
|
||||
result: blocked
|
||||
blocked_by: server
|
||||
reason: "Catalog is empty on test server — no items to browse. Needs seed data migration for test env."
|
||||
|
||||
### 5. Review screen
|
||||
expected: After selecting items, a review/summary screen shows all selections grouped by category.
|
||||
result: blocked
|
||||
blocked_by: server
|
||||
reason: "Depends on test 4 — no catalog items to select."
|
||||
|
||||
### 6. Completion and collection
|
||||
expected: After confirming, items are batch-added to collection with auto-created categories.
|
||||
result: blocked
|
||||
blocked_by: server
|
||||
reason: "Depends on test 4 — no catalog items to select."
|
||||
|
||||
### 7. Onboarding doesn't show again
|
||||
expected: Refresh the page or sign out and back in. Onboarding does NOT appear again.
|
||||
result: pass
|
||||
|
||||
## Summary
|
||||
|
||||
total: 7
|
||||
passed: 3
|
||||
issues: 1
|
||||
pending: 0
|
||||
skipped: 0
|
||||
blocked: 3
|
||||
|
||||
## Gaps
|
||||
|
||||
- truth: "Selected hobby cards should have strong visual distinction"
|
||||
status: failed
|
||||
reason: "User reported: selected cards need dark gray fill with inverted text/icon, not just border change"
|
||||
severity: cosmetic
|
||||
test: 3
|
||||
artifacts:
|
||||
- src/client/components/onboarding/OnboardingHobbyPicker.tsx
|
||||
missing:
|
||||
- Stronger selected state styling (dark bg, inverted colors)
|
||||
33
.planning/phases/31-mobile-polish/31-UAT.md
Normal file
33
.planning/phases/31-mobile-polish/31-UAT.md
Normal file
@@ -0,0 +1,33 @@
|
||||
---
|
||||
status: complete
|
||||
phase: 31-mobile-polish
|
||||
source: [31-01-SUMMARY.md, 31-02-SUMMARY.md]
|
||||
started: 2026-04-12T19:45:00Z
|
||||
updated: 2026-04-12T19:45:00Z
|
||||
---
|
||||
|
||||
## Current Test
|
||||
|
||||
[testing complete]
|
||||
|
||||
## Tests
|
||||
|
||||
### 1. Icon action buttons on mobile
|
||||
expected: Open an item detail page on mobile. Action buttons (Edit, Delete, Duplicate) show as icons only instead of text labels.
|
||||
result: pass
|
||||
|
||||
### 2. Icons across all detail pages
|
||||
expected: Candidate detail, setup detail, catalog item detail all have icon buttons on mobile too.
|
||||
result: pass
|
||||
|
||||
## Summary
|
||||
|
||||
total: 2
|
||||
passed: 2
|
||||
issues: 0
|
||||
pending: 0
|
||||
skipped: 0
|
||||
|
||||
## Gaps
|
||||
|
||||
[none]
|
||||
@@ -231,6 +231,10 @@ export function CollectionView() {
|
||||
imageUrl={item.imageUrl}
|
||||
productUrl={item.productUrl}
|
||||
brand={item.brand}
|
||||
dominantColor={item.dominantColor}
|
||||
cropZoom={item.cropZoom}
|
||||
cropX={item.cropX}
|
||||
cropY={item.cropY}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -264,8 +268,13 @@ export function CollectionView() {
|
||||
categoryName={categoryName}
|
||||
categoryIcon={categoryIcon}
|
||||
imageFilename={item.imageFilename}
|
||||
imageUrl={item.imageUrl}
|
||||
productUrl={item.productUrl}
|
||||
brand={item.brand}
|
||||
dominantColor={item.dominantColor}
|
||||
cropZoom={item.cropZoom}
|
||||
cropX={item.cropX}
|
||||
cropY={item.cropY}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -12,7 +12,7 @@ interface GearImageProps {
|
||||
export function GearImage({
|
||||
src,
|
||||
alt,
|
||||
dominantColor: _dominantColor,
|
||||
dominantColor,
|
||||
cropZoom,
|
||||
cropX,
|
||||
cropY,
|
||||
@@ -20,6 +20,9 @@ export function GearImage({
|
||||
cover = false,
|
||||
}: GearImageProps) {
|
||||
const hasCrop = cropZoom != null && cropZoom > 1;
|
||||
const bgStyle = dominantColor
|
||||
? { backgroundColor: dominantColor }
|
||||
: undefined;
|
||||
|
||||
if (cover) {
|
||||
return (
|
||||
@@ -33,24 +36,31 @@ export function GearImage({
|
||||
|
||||
if (hasCrop) {
|
||||
return (
|
||||
<img
|
||||
src={src}
|
||||
alt={alt}
|
||||
className={`w-full h-full object-cover ${className}`}
|
||||
style={{
|
||||
transform: `scale(${cropZoom}) translate(${cropX ?? 0}%, ${cropY ?? 0}%)`,
|
||||
transformOrigin: "center center",
|
||||
}}
|
||||
/>
|
||||
<div className="w-full h-full overflow-hidden" style={bgStyle}>
|
||||
<img
|
||||
src={src}
|
||||
alt={alt}
|
||||
className={`w-full h-full object-cover ${className}`}
|
||||
style={{
|
||||
transform: `scale(${cropZoom}) translate(${cropX ?? 0}%, ${cropY ?? 0}%)`,
|
||||
transformOrigin: "center center",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<img
|
||||
src={src}
|
||||
alt={alt}
|
||||
className={`w-full h-full object-contain ${className}`}
|
||||
/>
|
||||
<div
|
||||
className="w-full h-full flex items-center justify-center"
|
||||
style={bgStyle}
|
||||
>
|
||||
<img
|
||||
src={src}
|
||||
alt={alt}
|
||||
className={`w-full h-full object-contain ${className}`}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -125,7 +125,7 @@ export function ImageUpload({
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
d="M3 6h18M19 6v14a2 2 0 01-2 2H7a2 2 0 01-2-2V6m3 0V4a2 2 0 012-2h4a2 2 0 012 2v2"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
@@ -12,7 +12,7 @@ export function ProfileSection() {
|
||||
const [displayName, setDisplayName] = useState("");
|
||||
const [bio, setBio] = useState("");
|
||||
const [avatarUrl, setAvatarUrl] = useState<string | null>(null);
|
||||
const [initialized, setInitialized] = useState(false);
|
||||
const [dirty, setDirty] = useState(false);
|
||||
const [message, setMessage] = useState<{
|
||||
type: "success" | "error";
|
||||
text: string;
|
||||
@@ -21,13 +21,12 @@ export function ProfileSection() {
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (profile && !initialized) {
|
||||
if (profile && !dirty) {
|
||||
setDisplayName(profile.displayName ?? "");
|
||||
setBio(profile.bio ?? "");
|
||||
setAvatarUrl(profile.avatarUrl ?? null);
|
||||
setInitialized(true);
|
||||
}
|
||||
}, [profile, initialized]);
|
||||
}, [profile, dirty]);
|
||||
|
||||
async function handleSave(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
@@ -38,6 +37,7 @@ export function ProfileSection() {
|
||||
avatarUrl,
|
||||
bio: bio.trim() || undefined,
|
||||
});
|
||||
setDirty(false);
|
||||
setMessage({ type: "success", text: "Profile updated" });
|
||||
} catch (err) {
|
||||
setMessage({ type: "error", text: (err as Error).message });
|
||||
@@ -68,6 +68,7 @@ export function ProfileSection() {
|
||||
try {
|
||||
const result = await apiUpload<{ filename: string }>("/api/images", file);
|
||||
setAvatarUrl(result.filename);
|
||||
setDirty(true);
|
||||
} catch {
|
||||
setMessage({ type: "error", text: "Avatar upload failed." });
|
||||
} finally {
|
||||
@@ -147,7 +148,10 @@ export function ProfileSection() {
|
||||
{avatarUrl && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setAvatarUrl(null)}
|
||||
onClick={() => {
|
||||
setAvatarUrl(null);
|
||||
setDirty(true);
|
||||
}}
|
||||
className="block text-xs text-red-500 hover:text-red-700 mt-0.5"
|
||||
>
|
||||
Remove
|
||||
@@ -175,7 +179,10 @@ export function ProfileSection() {
|
||||
id="displayName"
|
||||
type="text"
|
||||
value={displayName}
|
||||
onChange={(e) => setDisplayName(e.target.value)}
|
||||
onChange={(e) => {
|
||||
setDisplayName(e.target.value);
|
||||
setDirty(true);
|
||||
}}
|
||||
maxLength={100}
|
||||
placeholder="Your display name"
|
||||
className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-gray-200"
|
||||
@@ -193,7 +200,10 @@ export function ProfileSection() {
|
||||
<textarea
|
||||
id="bio"
|
||||
value={bio}
|
||||
onChange={(e) => setBio(e.target.value)}
|
||||
onChange={(e) => {
|
||||
setBio(e.target.value);
|
||||
setDirty(true);
|
||||
}}
|
||||
maxLength={500}
|
||||
rows={3}
|
||||
placeholder="Tell others about yourself and your gear interests"
|
||||
|
||||
@@ -21,14 +21,26 @@ export function HobbyCard({
|
||||
onClick={onClick}
|
||||
className={`w-40 h-40 flex flex-col items-center justify-center gap-3 p-5 rounded-2xl cursor-pointer transition-all ${
|
||||
selected
|
||||
? "border-gray-700 ring-2 ring-gray-700/20 bg-white border"
|
||||
? "bg-gray-800 border border-gray-800 ring-2 ring-gray-700/20"
|
||||
: "bg-gray-50 border border-gray-200 hover:border-gray-300 hover:shadow-sm"
|
||||
}`}
|
||||
>
|
||||
<LucideIcon name={icon} size={32} className="text-gray-700" />
|
||||
<LucideIcon
|
||||
name={icon}
|
||||
size={32}
|
||||
className={selected ? "text-white" : "text-gray-700"}
|
||||
/>
|
||||
<div className="text-center">
|
||||
<div className="text-sm font-semibold text-gray-900">{name}</div>
|
||||
<div className="text-xs text-gray-400">{descriptor}</div>
|
||||
<div
|
||||
className={`text-sm font-semibold ${selected ? "text-white" : "text-gray-900"}`}
|
||||
>
|
||||
{name}
|
||||
</div>
|
||||
<div
|
||||
className={`text-xs ${selected ? "text-gray-300" : "text-gray-400"}`}
|
||||
>
|
||||
{descriptor}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
|
||||
@@ -273,9 +273,18 @@ function ItemDetail() {
|
||||
<ImageUpload
|
||||
value={form.imageFilename}
|
||||
imageUrl={imageUrl}
|
||||
dominantColor={item.dominantColor}
|
||||
onChange={(filename) =>
|
||||
setForm((f) => ({ ...f, imageFilename: filename }))
|
||||
}
|
||||
onCropChange={(crop) => {
|
||||
updateItem.mutate({
|
||||
id: item.id,
|
||||
cropZoom: crop.zoom,
|
||||
cropX: crop.x,
|
||||
cropY: crop.y,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
) : editingCrop && imageUrl ? (
|
||||
@@ -328,13 +337,23 @@ function ItemDetail() {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{imageUrl && !isEditing && (
|
||||
{imageUrl && isEditing && !isReference && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setEditingCrop(true)}
|
||||
className="mb-4 text-sm text-gray-500 hover:text-gray-700 transition-colors"
|
||||
className="mb-4 w-8 h-8 flex items-center justify-center rounded-full text-gray-400 hover:text-gray-600 hover:bg-gray-100 transition-colors"
|
||||
title="Adjust framing"
|
||||
>
|
||||
Adjust framing
|
||||
<svg
|
||||
className="w-4 h-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<path d="M6.13 1L6 16a2 2 0 0 0 2 2h15" />
|
||||
<path d="M1 6.13L16 6a2 2 0 0 1 2 2v15" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -38,6 +38,10 @@ export async function getAllItems(db: Db, userId: number) {
|
||||
brand: sql<
|
||||
string | null
|
||||
>`COALESCE(${globalItems.brand}, ${items.brand})`.as("brand"),
|
||||
dominantColor: items.dominantColor,
|
||||
cropZoom: items.cropZoom,
|
||||
cropX: items.cropX,
|
||||
cropY: items.cropY,
|
||||
createdAt: items.createdAt,
|
||||
updatedAt: items.updatedAt,
|
||||
categoryName: categories.name,
|
||||
@@ -82,6 +86,10 @@ export async function getItemById(db: Db, userId: number, id: number) {
|
||||
brand: sql<
|
||||
string | null
|
||||
>`COALESCE(${globalItems.brand}, ${items.brand})`.as("brand"),
|
||||
dominantColor: items.dominantColor,
|
||||
cropZoom: items.cropZoom,
|
||||
cropX: items.cropX,
|
||||
cropY: items.cropY,
|
||||
createdAt: items.createdAt,
|
||||
updatedAt: items.updatedAt,
|
||||
categoryName: categories.name,
|
||||
@@ -154,6 +162,10 @@ export async function updateItem(
|
||||
globalItemId: number;
|
||||
purchasePriceCents: number;
|
||||
brand: string;
|
||||
dominantColor: string | null;
|
||||
cropZoom: number | null;
|
||||
cropX: number | null;
|
||||
cropY: number | null;
|
||||
}>,
|
||||
) {
|
||||
// Check if item exists and belongs to user
|
||||
|
||||
Reference in New Issue
Block a user