Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
79 changes: 66 additions & 13 deletions apps/expo/features/packs/components/PackCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,20 +5,27 @@ import { Icon } from '@roninoss/icons';
import { WeightBadge } from 'expo-app/components/initial/WeightBadge';
import { useColorScheme } from 'expo-app/lib/hooks/useColorScheme';
import { router } from 'expo-router';
import { Alert, Image, Pressable, View } from 'react-native';
import { ActivityIndicator, Alert, Image, Pressable, View } from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { useDeletePack, usePackDetailsFromStore } from '../hooks';
import { useDeletePack, useDuplicatePack, usePackDetailsFromStore } from '../hooks';
import { usePackOwnershipCheck } from '../hooks/usePackOwnershipCheck';
import type { Pack, PackInStore } from '../types';

type PackCardProps = {
pack: Pack | PackInStore;
onPress?: (pack: Pack) => void;
isGenUI?: boolean; // Used to tweak styling & layout when card is being used in a generative UI context.
showDuplicateButton?: boolean;
};

export function PackCard({ pack: packArg, onPress, isGenUI = false }: PackCardProps) {
export function PackCard({
pack: packArg,
onPress,
isGenUI = false,
showDuplicateButton = false,
}: PackCardProps) {
const deletePack = useDeletePack();
const { duplicatePack, isLoading: isDuplicating } = useDuplicatePack();
const { colors } = useColorScheme();
const { showActionSheetWithOptions } = useActionSheet();
const insets = useSafeAreaInsets();
Expand Down Expand Up @@ -119,18 +126,64 @@ export function PackCard({ pack: packArg, onPress, isGenUI = false }: PackCardPr
</Text>
</View>

{pack.tags && isArray(pack.tags) && pack.tags.length > 0 && (
<View className="mt-3 flex-row flex-wrap">
{pack.tags.map((tag) => (
<View
key={tag}
className="mb-1 mr-2 rounded-full bg-neutral-200 px-2 py-1 dark:bg-neutral-700"
<View className="flex-row items-baseline justify-between">
{pack.tags && isArray(pack.tags) && pack.tags.length > 0 ? (
<View className="mt-3 flex-row flex-wrap">
{pack.tags.map((tag) => (
<View key={tag} className="mb-1 mr-2 rounded-full bg-background px-2 py-1">
<Text className="text-xs text-foreground">#{tag}</Text>
</View>
))}
</View>
) : null}

<View className="ml-auto flex-row items-center gap-2">
{/* Duplicate button for non-owned packs when showDuplicateButton is true */}
{!isOwnedByUser && showDuplicateButton && (
<Button
variant="plain"
size="icon"
disabled={isDuplicating}
onPress={() =>
Alert.alert(
'Duplicate pack?',
'This will create a copy of this pack in your collection.',
[
{ text: 'Cancel', style: 'cancel' },
{ text: 'Duplicate', onPress: () => duplicatePack(pack.id) },
],
)
}
>
{isDuplicating ? (
<ActivityIndicator size="small" color={colors.grey2} />
) : (
<Icon name="file-copy" size={21} color={colors.grey2} />
)}
</Button>
)}

{/* Delete button for owned packs */}
{!isGenUI && isOwnedByUser && (
<Button
variant="plain"
size="icon"
onPress={() =>
Alert.alert(
'Delete pack?',
'Are you sure you want to delete this pack? This action cannot be undone.',
[
{ text: 'Cancel', style: 'cancel' },
{ text: 'OK', style: 'destructive', onPress: () => deletePack(pack.id) },
],
)
}
>
<Text className="text-xs text-foreground">#{tag}</Text>
</View>
))}
<Icon name="trash-can" size={21} color={colors.grey2} />
</Button>
)}
</View>
)}
</View>
</View>
</Pressable>
);
Expand Down
2 changes: 2 additions & 0 deletions apps/expo/features/packs/hooks/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,14 @@ export * from './useAddCatalogItem';
export * from './useBulkAddCatalogItems';
export * from './useCategoriesCount';
export * from './useCreatePack';
export * from './useCreatePackFromPack';
export * from './useCreatePackItem';
export * from './useCreatePackWithItems';
export * from './useCurrentPack';
export * from './useDeletePack';
export * from './useDeletePackItem';
export * from './useDetailedPacks';
export * from './useDuplicatePack';
export * from './useHasMinimumInventory';
export * from './useImagePicker';
export * from './usePackDetailsFromApi';
Expand Down
56 changes: 56 additions & 0 deletions apps/expo/features/packs/hooks/useCreatePackFromPack.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { packItemsStore, packsStore } from 'expo-app/features/packs/store';
import { recordPackWeight } from 'expo-app/features/packs/store/packWeightHistory';
import type { Pack, PackInput, PackInStore } from 'expo-app/features/packs/types';
import { nanoid } from 'nanoid/non-secure';
import { useCallback } from 'react';

export function useCreatePackFromPack() {
const createPackFromPack = useCallback((sourcePack: Pack, packData: Partial<PackInput>) => {
const newPackId = nanoid();
const timestamp = new Date().toISOString();

// Create the new pack with custom data, falling back to source pack data
const newPack: PackInStore = {
id: newPackId,
name: packData.name || `${sourcePack.name} (Copy)`,
description: packData.description || sourcePack.description,
Comment on lines +15 to +16
Copy link

Copilot AI Apr 11, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

packData.name / packData.description are merged using ||, which will treat empty strings as “not provided” and fall back to the source pack values. If callers ever intentionally pass an empty string (e.g., to clear description), this will be impossible.

Use nullish coalescing (??) for string fields so only undefined/null trigger the fallback.

Suggested change
name: packData.name || `${sourcePack.name} (Copy)`,
description: packData.description || sourcePack.description,
name: packData.name ?? `${sourcePack.name} (Copy)`,
description: packData.description ?? sourcePack.description,

Copilot uses AI. Check for mistakes.
category: packData.category || sourcePack.category,
isPublic: packData.isPublic !== undefined ? packData.isPublic : false, // Default to private
image: packData.image !== undefined ? packData.image : sourcePack.image,
tags: packData.tags || sourcePack.tags || [],
localCreatedAt: timestamp,
localUpdatedAt: timestamp,
deleted: false,
};

// @ts-ignore: Safe because Legend-State uses Proxy
packsStore[newPackId].set(newPack);
Comment on lines +26 to +27
Copy link

Copilot AI Apr 11, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This hook uses // @ts-ignore and direct index access on Legend-State stores (packsStore[newPackId].set(...), packItemsStore[newItemId].set(...)). Elsewhere in the packs feature the established pattern is to use the obs(store, id) helper (e.g. apps/expo/features/packs/hooks/useCreatePack.ts:21, useCreatePackItem.ts:31) which preserves typing and avoids suppressing errors.

Consider switching these writes to obs(packsStore, newPackId).set(...) / obs(packItemsStore, newItemId).set(...) and dropping the @ts-ignore comments.

Copilot uses AI. Check for mistakes.

// Copy each item from the source pack
if (sourcePack.items) {
for (const item of sourcePack.items) {
if (!item.deleted) {
const newItemId = nanoid();
const newItem = {
...item,
id: newItemId,
packId: newPackId,
deleted: false,
createdAt: undefined, // Reset server timestamps
updatedAt: undefined,
};

// @ts-ignore: Safe because Legend-State uses Proxy
packItemsStore[newItemId].set(newItem);
}
}
}

// Recalculate pack weight
recordPackWeight(newPackId);

return newPackId;
}, []);

return createPackFromPack;
}
49 changes: 49 additions & 0 deletions apps/expo/features/packs/hooks/useDuplicatePack.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import axiosInstance from 'expo-app/lib/api/client';
import { useCallback } from 'react';
import type { Pack, PackInput } from '../types';
import { useCreatePackFromPack } from './useCreatePackFromPack';

export function useDuplicatePack() {
const createPackFromPack = useCreatePackFromPack();
const queryClient = useQueryClient();

const duplicatePackMutation = useMutation({
mutationFn: async ({
packId,
packData = {},
}: {
packId: string;
packData?: Partial<PackInput>;
}) => {
// First, fetch the full pack details with items
const response = await queryClient.fetchQuery({
queryKey: ['pack', packId],
queryFn: async () => {
const res = await axiosInstance.get(`/api/packs/${packId}`);
return res.data as Pack;
},
});

// Create the new pack from the fetched pack
const newPackId = createPackFromPack(response, packData);
return newPackId;
},
onError: (error) => {
console.error('Error duplicating pack:', error);
},
});

const duplicatePack = useCallback(
(packId: string, packData?: Partial<PackInput>) => {
return duplicatePackMutation.mutateAsync({ packId, packData });
},
[duplicatePackMutation],
);

return {
duplicatePack,
isLoading: duplicatePackMutation.isPending,
error: duplicatePackMutation.error,
};
}
6 changes: 5 additions & 1 deletion apps/expo/features/packs/screens/PackListScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -215,7 +215,11 @@ export function PackListScreen() {
stickyHeaderIndices={[0]}
renderItem={({ item: pack }) => (
<View className="px-4 pt-4">
<PackCard pack={pack} onPress={handlePackPress} />
<PackCard
pack={pack}
onPress={handlePackPress}
showDuplicateButton={selectedTypeIndex === ALL_PACKS_INDEX}
/>
</View>
)}
refreshControl={
Expand Down
16 changes: 16 additions & 0 deletions apps/expo/features/trips/components/TripForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,22 @@ export const TripForm = ({ trip }: { trip?: Trip }) => {
const { location, setLocation } = useTripLocation();
const packs = usePacks();

// Initialize location store with trip's location when component mounts or
// trip ID changes. We intentionally depend only on trip?.id (not trip?.location)
// so that after the user picks a new location via location-search, a
// re-render of the same trip object does not overwrite their selection in
// the store.
// biome-ignore lint/correctness/useExhaustiveDependencies: intentional — see comment above; reseeding on trip?.location would stomp user-picked values
useEffect(() => {
// Set location from trip, or null if trip has no location
setLocation(trip?.location ?? null);

// Cleanup: clear location when component unmounts
return () => {
Comment on lines +67 to +71
Copy link

Copilot AI Apr 11, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new effect seeds the shared location store from trip?.location, but the existing effect later in this component (useEffect(() => { tripLocationStore.set(null) ... }, [])) will run after this one on mount and immediately clear the store back to null. That makes the seeded location unavailable when editing an existing trip.

Consider removing the older “reset store on mount” effect, or folding the stale-state reset into this effect (e.g., only clear on mount when !trip, or clear first and then seed), so the final mounted state matches trip?.location when editing.

Suggested change
// Set location from trip, or null if trip has no location
setLocation(trip?.location ?? null);
// Cleanup: clear location when component unmounts
return () => {
const nextLocation = trip?.location ?? null;
// Defer the seed so the final mounted store state matches the current
// trip, even if another mount-only effect in this component clears the
// shared location store during the same commit.
const timeoutId = setTimeout(() => {
setLocation(nextLocation);
}, 0);
// Cleanup: cancel pending seed and clear location when component unmounts
return () => {
clearTimeout(timeoutId);

Copilot uses AI. Check for mistakes.
setLocation(null);
};
}, [trip?.id, setLocation]);

const [showPackModal, setShowPackModal] = useState(false);
const [showEndPicker, setShowEndPicker] = useState(false);
const [showStartPicker, setShowStartPicker] = useState(false);
Expand Down
13 changes: 12 additions & 1 deletion apps/expo/features/trips/screens/TripDetailScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import { useLocations } from 'expo-app/features/weather/hooks';
import { useColorScheme } from 'expo-app/lib/hooks/useColorScheme';
import { useTranslation } from 'expo-app/lib/hooks/useTranslation';
import { useLocalSearchParams, useRouter } from 'expo-router';
import { useState } from 'react';
import { useMemo, useState } from 'react';
import { Modal, ScrollView, Share, View } from 'react-native';
import MapView, { Marker, PROVIDER_GOOGLE } from 'react-native-maps';
import { SafeAreaView } from 'react-native-safe-area-context';
Expand All @@ -30,6 +30,16 @@ export function TripDetailScreen() {
const trip = useTripDetailsFromStore(id as string) as Trip;
const packs = useDetailedPacks();

// Create a stable key for MapView based on location coordinates
// This forces remount when location changes, fixing iOS initialRegion issue
const mapKey = useMemo(
() =>
trip?.location
? `map-${trip.location.latitude}-${trip.location.longitude}`
: 'map-no-location',
[trip?.location],
);

if (!trip) {
return (
<SafeAreaView className="flex-1 bg-background items-center justify-center">
Expand Down Expand Up @@ -159,6 +169,7 @@ export function TripDetailScreen() {

<View className="h-36">
<MapView
key={mapKey}
provider={PROVIDER_GOOGLE}
style={{ flex: 1 }}
initialRegion={{
Expand Down
7 changes: 4 additions & 3 deletions apps/expo/features/trips/store/tripLocationStore.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
import { observable } from '@legendapp/state';
import { use$ } from '@legendapp/state/react';
import { useCallback } from 'react';

/**
* Type for the trip location.
*/
export type TripLocation = {
name: string;
name?: string;
latitude: number;
longitude: number;
};
Expand All @@ -22,9 +23,9 @@ export const tripLocationStore = observable<TripLocation | null>(null);
export function useTripLocation() {
const location = use$(() => tripLocationStore.get());

const setLocation = (loc: TripLocation | null) => {
const setLocation = useCallback((loc: TripLocation | null) => {
tripLocationStore.set(loc);
};
}, []);

return { location, setLocation };
}
26 changes: 8 additions & 18 deletions apps/landing/components/sections/download.tsx
Original file line number Diff line number Diff line change
@@ -1,21 +1,9 @@
'use client';

import { siteConfig } from 'landing-app/config/site';
import { Apple, Check, Store } from 'lucide-react';
import Image from 'next/image';
import Link from 'next/link';
import type React from 'react';

export default function DownloadSection() {
const scrollToSection = (e: React.MouseEvent<HTMLAnchorElement>, href: string) => {
e.preventDefault();
const targetId = href.substring(1);
const element = document.getElementById(targetId);
if (element) {
element.scrollIntoView({ behavior: 'smooth' });
}
};

return (
<section id="download" className="py-20 md:py-28 lg:py-36 relative overflow-hidden">
{/* Subtle Apple-style background gradient */}
Expand All @@ -25,8 +13,8 @@ export default function DownloadSection() {
<div className="apple-card overflow-hidden">
<div className="grid lg:grid-cols-2 items-center gap-8 md:gap-12 p-6 md:p-8 lg:p-12">
{/* Text content */}
<div className="space-y-4 md:space-y-6 max-w-xl">
<div className="apple-badge">
<div className="space-y-4 md:space-y-6 max-w-xl mx-auto lg:mx-0 text-center lg:text-left">
<div className="apple-badge mx-auto lg:mx-0 w-fit">
<span className="mr-1.5 h-2 w-2 rounded-full animate-pulse bg-apple-blue inline-block" />
Get Started Today
</div>
Expand All @@ -39,7 +27,7 @@ export default function DownloadSection() {
{siteConfig.download.subtitle}
</p>

<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 md:gap-4">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 md:gap-4 text-left">
{['Free to use', 'Offline access', 'Regular updates', 'Community support'].map(
(item) => (
<div key={item} className="flex items-start gap-2">
Expand All @@ -52,10 +40,11 @@ export default function DownloadSection() {
)}
</div>

<div className="flex flex-col sm:flex-row gap-3 pt-4">
<div className="flex flex-col sm:flex-row justify-center lg:justify-start gap-3 pt-4">
<Link
href={siteConfig.download.appStoreLink}
onClick={(e) => scrollToSection(e, siteConfig.download.appStoreLink)}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center justify-center gap-2 rounded-full bg-apple-blue text-white px-8 h-12 text-sm font-medium hover:bg-apple-blue/90 transition-colors"
>
<Apple className="h-5 w-5" />
Expand All @@ -64,7 +53,8 @@ export default function DownloadSection() {

<Link
href={siteConfig.download.googlePlayLink}
onClick={(e) => scrollToSection(e, siteConfig.download.googlePlayLink)}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center justify-center gap-2 rounded-full border border-border bg-background px-8 h-12 text-sm font-medium hover:bg-black/5 dark:hover:bg-white/10 transition-colors"
>
<Store className="h-5 w-5" />
Expand Down
Loading