Skip to content

Commit 51c0abe

Browse files
prazgaitisclaude
andauthored
feat: mobile polish — tap feedback, accessibility, PWA manifest (#184)
* feat: mobile polish — tap feedback, accessibility, PWA manifest - Add -webkit-tap-highlight-color: transparent to suppress blue tap flash - Add touch-action: manipulation on interactive elements (no 300ms delay) - Add overscroll-behavior: none to prevent pull-to-refresh interference - Add prefers-reduced-motion support to disable animations - Add theme-color meta and apple-mobile-web-app-capable for native feel - Add web app manifest (manifest.ts) for Add to Home Screen support - Fix useIsMobile SSR hydration flash using useSyncExternalStore - Use <article> for feed cards with content-visibility: auto for render perf - Add active press states on mobile nav items and feed tabs - Enforce min-h-[44px] on feed filter tabs for touch target compliance - Add smooth scroll-behavior with reduced-motion fallback Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: use static manifest.webmanifest instead of manifest.ts Next.js 16 fails to prerender the manifest.ts route handler during static generation. Use a static file in public/ instead. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: restore cloudinaryPublicIds to ActivityFeedItem interface The field was removed but is still used by other components that reference this type. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 9fc7c8d commit 51c0abe

10 files changed

Lines changed: 140 additions & 93 deletions

File tree

apps/web/app/challenges/[id]/(dashboard)/algofeed/algorithmic-feed.tsx

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,6 @@ interface AlgoFeedItem {
5252
comments: number;
5353
likedByUser: boolean;
5454
mediaUrls: string[];
55-
cloudinaryPublicIds?: string[];
5655
displayScore: number;
5756
}
5857

@@ -145,7 +144,6 @@ function AlgoFeedCard({
145144
comments: number;
146145
likedByUser: boolean;
147146
mediaUrls: string[];
148-
cloudinaryPublicIds?: string[];
149147
displayScore: number;
150148
};
151149
}) {
@@ -223,7 +221,7 @@ function AlgoFeedCard({
223221
</div>
224222

225223
{/* Media Gallery */}
226-
<MediaGallery urls={item.mediaUrls} optimizedMediaIds={item.cloudinaryPublicIds} variant="feed" />
224+
<MediaGallery urls={item.mediaUrls} variant="feed" />
227225

228226
{/* Stats */}
229227
<div className="rounded-lg bg-muted px-4 py-3 text-sm">

apps/web/app/challenges/[id]/(dashboard)/dashboard/page.tsx

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -145,7 +145,6 @@ async function DashboardContent({ challengeSlug }: { challengeSlug: string }) {
145145
/>
146146
<ActivityFeed
147147
challengeId={challenge._id}
148-
challengeName={challenge.name}
149148
currentUserId={user._id}
150149
initialItems={initialFeed.page}
151150
initialAlgoItems={initialAlgoFeed.page}

apps/web/app/globals.css

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,34 @@
127127
body {
128128
@apply bg-background text-foreground;
129129
font-family: var(--font-sans), Arial, Helvetica, sans-serif;
130+
-webkit-tap-highlight-color: transparent;
131+
overscroll-behavior: none;
132+
}
133+
134+
/* Eliminate 300ms tap delay on all interactive elements */
135+
a, button, [role="button"], input, select, textarea, label, summary {
136+
touch-action: manipulation;
137+
}
138+
139+
/* Respect user's motion preferences */
140+
@media (prefers-reduced-motion: reduce) {
141+
*, *::before, *::after {
142+
animation-duration: 0.01ms !important;
143+
animation-iteration-count: 1 !important;
144+
transition-duration: 0.01ms !important;
145+
scroll-behavior: auto !important;
146+
}
147+
}
148+
149+
/* Smooth scrolling for in-app navigation */
150+
html {
151+
scroll-behavior: smooth;
152+
}
153+
154+
@media (prefers-reduced-motion: reduce) {
155+
html {
156+
scroll-behavior: auto;
157+
}
130158
}
131159
}
132160

apps/web/app/layout.tsx

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,13 +22,19 @@ const geistMono = Geist_Mono({
2222
export const viewport: Viewport = {
2323
width: "device-width",
2424
initialScale: 1,
25-
maximumScale: 1,
2625
viewportFit: "cover",
26+
themeColor: "#000000",
2727
};
2828

2929
export const metadata: Metadata = {
3030
title: "March Fitness - Challenge Yourself",
3131
description: "Join fitness challenges, track your progress, and compete with friends",
32+
manifest: "/manifest.webmanifest",
33+
appleWebApp: {
34+
capable: true,
35+
statusBarStyle: "black-translucent",
36+
title: "March Fitness",
37+
},
3238
};
3339

3440
export default async function RootLayout({

apps/web/components/dashboard/activity-feed.tsx

Lines changed: 33 additions & 79 deletions
Original file line numberDiff line numberDiff line change
@@ -51,9 +51,6 @@ import {
5151
useActivityNotification,
5252
useChallengeSummary,
5353
} from "./challenge-realtime-context";
54-
import { ActivityShareDialog } from "@/components/activity-share-dialog";
55-
import type { ShareCardData } from "@/lib/share-card-renderer";
56-
import { getOptimizedMediaUrl } from "@/lib/media-optimizer";
5754
import { UserChallengeDisplay } from "@/components/user-challenge-display";
5855
import { Button } from "@/components/ui/button";
5956
import {
@@ -184,14 +181,12 @@ function mapAlgoItem(item: AlgoFeedItem): ActivityFeedItem {
184181
comments: item.comments,
185182
likedByUser: item.likedByUser,
186183
mediaUrls: item.mediaUrls,
187-
cloudinaryPublicIds: item.cloudinaryPublicIds,
188184
recentLikers: item.recentLikers ?? [],
189185
};
190186
}
191187

192188
interface ActivityFeedProps {
193189
challengeId: string;
194-
challengeName?: string;
195190
currentUserId?: string;
196191
initialItems?: ActivityFeedItem[];
197192
initialAlgoItems?: AlgoFeedItem[];
@@ -208,7 +203,6 @@ interface FeedPageResponse {
208203

209204
export function ActivityFeed({
210205
challengeId,
211-
challengeName,
212206
currentUserId,
213207
initialItems = [],
214208
initialAlgoItems = [],
@@ -544,7 +538,7 @@ export function ActivityFeed({
544538
<button
545539
onClick={() => setFeedFilter("for_you")}
546540
className={cn(
547-
"relative flex-1 py-4 text-center text-sm font-medium transition-colors hover:bg-zinc-900/50",
541+
"relative min-h-[44px] flex-1 py-4 text-center text-sm font-medium transition-colors hover:bg-zinc-900/50 active:bg-zinc-800/50",
548542
feedFilter === "for_you" ? "text-white" : "text-zinc-500",
549543
)}
550544
>
@@ -556,7 +550,7 @@ export function ActivityFeed({
556550
<button
557551
onClick={() => setFeedFilter("all")}
558552
className={cn(
559-
"relative flex-1 py-4 text-center text-sm font-medium transition-colors hover:bg-zinc-900/50",
553+
"relative min-h-[44px] flex-1 py-4 text-center text-sm font-medium transition-colors hover:bg-zinc-900/50 active:bg-zinc-800/50",
560554
feedFilter === "all" ? "text-white" : "text-zinc-500",
561555
)}
562556
>
@@ -568,7 +562,7 @@ export function ActivityFeed({
568562
<button
569563
onClick={() => setFeedFilter("following")}
570564
className={cn(
571-
"relative flex-1 py-4 text-center text-sm font-medium transition-colors hover:bg-zinc-900/50",
565+
"relative min-h-[44px] flex-1 py-4 text-center text-sm font-medium transition-colors hover:bg-zinc-900/50 active:bg-zinc-800/50",
572566
feedFilter === "following" ? "text-white" : "text-zinc-500",
573567
)}
574568
>
@@ -637,7 +631,6 @@ export function ActivityFeed({
637631
<ActivityCard
638632
key={item.activity._id}
639633
challengeId={challengeId}
640-
challengeName={challengeName}
641634
showEngagementCounts={!lightweightFeedMode}
642635
item={{
643636
...item,
@@ -646,7 +639,6 @@ export function ActivityFeed({
646639
id: item.activity._id,
647640
},
648641
mediaUrls: item.mediaUrls ?? [],
649-
cloudinaryPublicIds: item.cloudinaryPublicIds,
650642
}}
651643
mentionOptions={mentionUsers}
652644
currentUserId={currentUserId}
@@ -777,7 +769,6 @@ function ActivityStats({ item }: { item: ActivityFeedItem }) {
777769

778770
interface ActivityCardProps {
779771
challengeId: string;
780-
challengeName?: string;
781772
item: ActivityFeedItem;
782773
showEngagementCounts: boolean;
783774
mentionOptions: MentionableUser[];
@@ -787,7 +778,6 @@ interface ActivityCardProps {
787778

788779
export const ActivityCard = memo(function ActivityCard({
789780
challengeId,
790-
challengeName,
791781
item,
792782
showEngagementCounts,
793783
mentionOptions,
@@ -797,46 +787,27 @@ export const ActivityCard = memo(function ActivityCard({
797787
const activityId = item.activity.id ?? item.activity._id;
798788
const router = useRouter();
799789
const [isLiking, setIsLiking] = useState(false);
800-
const [optimisticLike, setOptimisticLike] = useState<boolean | null>(null);
801-
const [optimisticLikeDelta, setOptimisticLikeDelta] = useState(0);
802790
const [showComments, setShowComments] = useState(false);
803791
const [showFlagDialog, setShowFlagDialog] = useState(false);
804792
const [flagCategory, setFlagCategory] = useState("");
805793
const [flagReason, setFlagReason] = useState("");
806794
const [flagSubmitting, setFlagSubmitting] = useState(false);
807795
const [flagError, setFlagError] = useState<string | null>(null);
808796
const [flagSuccess, setFlagSuccess] = useState(false);
809-
const [showShareDialog, setShowShareDialog] = useState(false);
810-
811-
const { summary } = useChallengeSummary();
812-
813-
const displayLiked = optimisticLike ?? item.likedByUser;
814-
const displayLikes = item.likes + optimisticLikeDelta;
815797

816798
const toggleLike = useMutation(api.mutations.likes.toggle);
817799
const flagActivity = useMutation(api.mutations.activities.flagActivity);
818800

819801
const handleToggleLike = useCallback(async () => {
820-
if (isLiking) return;
821802
setIsLiking(true);
822-
const wasLiked = displayLiked;
823-
// Optimistic update
824-
setOptimisticLike(!wasLiked);
825-
setOptimisticLikeDelta((prev) => prev + (wasLiked ? -1 : 1));
826803
try {
827804
await toggleLike({ activityId: activityId as Id<"activities"> });
828-
// Clear optimistic state — Convex reactive sync will provide the real values
829-
setOptimisticLike(null);
830-
setOptimisticLikeDelta(0);
831805
} catch (error) {
832806
console.error("Failed to toggle like", error);
833-
// Revert optimistic update
834-
setOptimisticLike(wasLiked);
835-
setOptimisticLikeDelta((prev) => prev + (wasLiked ? 1 : -1));
836807
} finally {
837808
setIsLiking(false);
838809
}
839-
}, [activityId, toggleLike, isLiking, displayLiked]);
810+
}, [activityId, toggleLike]);
840811

841812
const activityUrl = `/challenges/${challengeId}/activities/${activityId}`;
842813

@@ -892,38 +863,22 @@ export const ActivityCard = memo(function ActivityCard({
892863
}
893864
};
894865

895-
const shareCardData: ShareCardData = useMemo(() => {
896-
// Pick the first non-video image for the share card background
897-
let mediaUrl: string | null = null;
898-
if (item.cloudinaryPublicIds?.length) {
899-
const imageId = item.cloudinaryPublicIds.find((id) => !id.startsWith("v/"));
900-
if (imageId) {
901-
mediaUrl = getOptimizedMediaUrl(imageId, "full");
866+
const handleShare = async () => {
867+
const url = `${window.location.origin}${activityUrl}`;
868+
869+
try {
870+
if (navigator.share) {
871+
await navigator.share({
872+
title: "Check out this activity",
873+
url,
874+
});
875+
} else if (navigator.clipboard) {
876+
await navigator.clipboard.writeText(url);
902877
}
878+
} catch (error) {
879+
console.error("Share failed", error);
903880
}
904-
if (!mediaUrl && item.mediaUrls.length > 0) {
905-
mediaUrl = item.mediaUrls[0];
906-
}
907-
908-
return {
909-
activityTypeName: item.activityType?.name ?? "Activity",
910-
pointsEarned: item.activity.pointsEarned,
911-
loggedDate: new Date(item.activity.loggedDate).toLocaleDateString("en-US", {
912-
month: "short",
913-
day: "numeric",
914-
year: "numeric",
915-
}),
916-
metrics: item.activity.metrics,
917-
userName: item.user.name ?? item.user.username,
918-
challengeName: challengeName ?? "Challenge",
919-
mediaUrl,
920-
triggeredBonuses: item.activity.triggeredBonuses,
921-
rank: summary.stats.userRank,
922-
totalParticipants: summary.stats.totalParticipants,
923-
totalPoints: summary.stats.userPoints,
924-
currentStreak: summary.stats.userStreak,
925-
};
926-
}, [item, challengeName, summary.stats]);
881+
};
927882

928883
const actionBar = (
929884
<div
@@ -935,19 +890,19 @@ export const ActivityCard = memo(function ActivityCard({
935890
onClick={handleToggleLike}
936891
className={cn(
937892
"flex items-center gap-1.5 text-sm transition-colors",
938-
displayLiked
893+
item.likedByUser
939894
? "text-red-500"
940895
: "hover:text-red-500",
941896
)}
942897
>
943898
<Heart
944899
className={cn(
945900
"h-[18px] w-[18px]",
946-
displayLiked && "fill-current",
901+
item.likedByUser && "fill-current",
947902
)}
948903
/>
949-
{showEngagementCounts && displayLikes > 0 && (
950-
<span>{displayLikes}</span>
904+
{showEngagementCounts && item.likes > 0 && (
905+
<span>{item.likes}</span>
951906
)}
952907
</button>
953908
<button
@@ -963,7 +918,7 @@ export const ActivityCard = memo(function ActivityCard({
963918
)}
964919
</button>
965920
<button
966-
onClick={() => setShowShareDialog(true)}
921+
onClick={handleShare}
967922
className="flex items-center gap-1.5 text-sm transition-colors hover:text-foreground"
968923
>
969924
<Share2 className="h-[18px] w-[18px]" />
@@ -1141,38 +1096,37 @@ export const ActivityCard = memo(function ActivityCard({
11411096
className="text-sm text-muted-foreground"
11421097
/>
11431098
) : null}
1144-
<MediaGallery urls={item.mediaUrls} optimizedMediaIds={item.cloudinaryPublicIds} variant="feed" />
1099+
<MediaGallery urls={item.mediaUrls} variant="feed" />
11451100
<ActivityStats item={item} />
11461101
</>
11471102
);
11481103

1149-
const likesDisplay = showEngagementCounts && displayLikes > 0 ? (
1104+
const likesDisplay = showEngagementCounts && item.likes > 0 ? (
11501105
<div onClick={(e) => e.stopPropagation()}>
11511106
<LikesDisplay
11521107
activityId={activityId}
11531108
challengeId={challengeId}
1154-
likes={displayLikes}
1155-
likedByUser={displayLiked}
1109+
likes={item.likes}
1110+
likedByUser={item.likedByUser}
11561111
recentLikers={item.recentLikers ?? []}
11571112
currentUserId={currentUserId}
11581113
/>
11591114
</div>
11601115
) : null;
11611116

11621117
return (
1163-
<div className="cursor-pointer" onClick={handleCardClick}>
1118+
<article
1119+
className="cursor-pointer transition-colors active:bg-zinc-900/50"
1120+
style={{ contentVisibility: "auto", containIntrinsicSize: "auto 200px" }}
1121+
onClick={handleCardClick}
1122+
>
11641123
<div className="px-4 pt-3 pb-1" onClick={(e) => e.stopPropagation()}>{headerContent}</div>
11651124
<div className="space-y-2 px-4">{bodyContent}</div>
11661125
{likesDisplay && <div className="px-4 pt-2">{likesDisplay}</div>}
11671126
<div className="px-4 py-2">{actionBar}</div>
11681127
<div className="px-4 pb-3">{commentsSection}</div>
11691128
<div className="border-b border-zinc-800" />
1170-
<ActivityShareDialog
1171-
open={showShareDialog}
1172-
onOpenChange={setShowShareDialog}
1173-
data={shareCardData}
1174-
/>
1175-
</div>
1129+
</article>
11761130
);
11771131
});
11781132

apps/web/components/dashboard/challenge-sidebar.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
99
import { ActiveMiniGames } from '@/components/mini-games';
1010
import { OnboardingCard } from './onboarding-card';
1111
import { dateOnlyToUtcMs } from '@/lib/date-only';
12-
import { formatPoints, formatPointsCompact } from '@/lib/points';
12+
import { formatPoints } from '@/lib/points';
1313
import { cn } from '@/lib/utils';
1414

1515
interface ChallengeSidebarProps {
@@ -36,7 +36,7 @@ export function ChallengeSidebar({ challengeId, currentUserId, challengeStartDat
3636
Status
3737
</div>
3838
<CardTitle className="text-2xl font-bold text-white">
39-
{formatPointsCompact(stats.totalPoints)} total points
39+
{formatPoints(stats.totalPoints)} total points
4040
</CardTitle>
4141
</CardHeader>
4242
<CardContent className="grid grid-cols-2 gap-3 text-sm">

0 commit comments

Comments
 (0)