Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
a1d6e6d
add gift cards
gudnuf Jan 6, 2026
284070a
refactor Page component
gudnuf Jan 9, 2026
6cf5e2a
feedback for transactions
gudnuf Jan 9, 2026
70fb288
add useAccountOrDefault and remove search param loaders
gudnuf Jan 9, 2026
5939a57
Refactor to use view transitions for card animations (#765)
jbojcic1 Jan 12, 2026
9c24bbb
fix add gift card style
gudnuf Jan 13, 2026
c7ac9f9
new images
gudnuf Jan 13, 2026
bbabe9d
hide linear-gradient with rest of hidden overlay
gudnuf Jan 13, 2026
acebd8a
make discover section overflow to edge of screen
gudnuf Jan 13, 2026
790d9c0
useViewTransitionState to conditionally apply transition names
gudnuf Jan 14, 2026
b365dfc
ui nits
gudnuf Jan 14, 2026
7756bc8
Use new API keys approach
jbojcic1 Jan 15, 2026
c61287c
Update readme to explain OS project configurations
jbojcic1 Jan 16, 2026
36ec3a2
Add lazy loading for gift card images with WebP conversion
gudnuf Jan 15, 2026
4e95e9f
update images and add mention nix for cwebp
gudnuf Jan 27, 2026
9583e7d
Use view transitions to animate discover cards
gudnuf Jan 14, 2026
1f8b154
add account purpose and some more changes
gudnuf Jan 27, 2026
9386377
rename to getGiftCardImageByUrl
gudnuf Jan 27, 2026
2192510
add TransactionCache.HistoryKey
gudnuf Jan 27, 2026
b6e9ea6
ack status store improvements
gudnuf Jan 27, 2026
e55bd98
improve docs for iOS rootCA
gudnuf Jan 27, 2026
a202f31
remove conditional rendering from gift cards
gudnuf Jan 27, 2026
2bb2d93
nits
gudnuf Jan 28, 2026
27f4499
Revert "Use new API keys approach"
gudnuf Jan 30, 2026
b7ce844
add gift card feature flag
gudnuf Jan 30, 2026
d48c306
css nit
gudnuf Jan 30, 2026
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
3 changes: 2 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -21,4 +21,5 @@ LNURL_SERVER_ENCRYPTION_KEY="a8861cc3e5b3caf5573fbba2da2851a4e608a836eef10074be3
VITE_CASHU_MINT_BLOCKLIST='[{"mintUrl":"https://mint.lnvoltz.com","unit":"usd"}]'

# Feature Flags
VITE_FF_GUEST_SIGNUP=true
VITE_FF_GUEST_SIGNUP=true
VITE_FF_GIFT_CARDS=true
3 changes: 2 additions & 1 deletion .env.test
Original file line number Diff line number Diff line change
Expand Up @@ -16,4 +16,5 @@ LNURL_SERVER_ENCRYPTION_KEY="a8861cc3e5b3caf5573fbba2da2851a4e608a836eef10074be3
VITE_CASHU_MINT_BLOCKLIST='[{"mintUrl":"https://mint.lnvoltz.com","unit":"usd"}]'

# Feature Flags
VITE_FF_GUEST_SIGNUP=true
VITE_FF_GUEST_SIGNUP=true
VITE_FF_GIFT_CARDS=true
63 changes: 56 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,11 @@ bun run supabase start
bun run dev
```

Note that when running locally the app is still using hosted Open Secret environment which is dedicated for the local development, while
our hosted envs have their own dedicated Open Secret environments. This means that even while working on your machine you still need
internet connection for identity/auth, key management, etc. Configurations for the local Open Secret environment can be seen in
[Agicash local](https://app.opensecret.cloud/orgs/a92d07fb-3837-42e0-9a83-f109c425391e/projects/77f98196-3e2f-43ad-83d1-decdac01b0fa) project.

When testing the app on an actual mobile device, you need to connect to the same Wi-Fi as the machine hosting the app
and access it via local IP or hostname. Unlike localhost or 127.0.0.1, those are not considered a safe context by the
browser, so the browser APIs that require a safe context won't work. To solve this issue, you need to run the app on HTTPS
Expand All @@ -49,12 +54,10 @@ A self-signed certificate is used for HTTPS. The certificate is managed by deven
regenerate the certificate (for example, if your local IP has changed), reload devenv by executing `direnv reload`
or run the certificate script directly by executing `generate-ssl-cert`.

**Installing the root certificate on mobile:** Mobile browsers may require the root CA to be installed and trusted on the
device. On **iOS**:

1. AirDrop or email the `certs/rootCA.pem` file to your device and open it
2. Go to **Settings → General → VPN & Device Management** and install the downloaded profile
3. Go to **Settings → General → About → Certificate Trust Settings** and enable full trust for the root certificate
**Installing the root certificate on iOS:** Mobile browsers require the root CA to be trusted. Find your mkcert root CA
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Originally claude copied my rootCa to my agicash/certs dir, so then that's what these previous docs were pointing to. I had to go through this again, and realized this is wrong because that's not where the cert actually lives

by running `mkcert -CAROOT` (typically `~/Library/Application Support/mkcert/rootCA.pem`), then AirDrop or email it to
your device. Install via **Settings → General → VPN & Device Management**, then enable trust in **Settings → General →
About → Certificate Trust Settings**.

`master` is the main branch. When working on a feature, branch off `master` and, when ready, make a PR back to `master`.
Try to make feature branches short-lived and concise (avoid implementing multiple features in one PR).
Expand Down Expand Up @@ -101,6 +104,27 @@ branch/environment is automatically deleted once the feature branch is merged.

To release a new `alpha` version, make a pull request from `master` to the `alpha` branch.

`alpha` environment has dedicated [Agicash Alpha](https://app.opensecret.cloud/orgs/a92d07fb-3837-42e0-9a83-f109c425391e/projects/ae22a864-b2f7-4e7e-bef0-2d194e0d20b6)
Open Secret environment.
`next` environment has dedicated [Agicash Next](https://app.opensecret.cloud/orgs/a92d07fb-3837-42e0-9a83-f109c425391e/projects/343cef25-2328-43b8-abc0-68a433bfc40e)
Open Secret environment.

All preview deployments of the Agicash app are also using Agicash Next Open Secret environment. However, since Supabase envs for preview
deployments are created on demand and currently there is no way to configure static JWT secret for them, if you want the preview deployment to work with Open Secret
you need to copy the shared secret value used by Agicash Next Open Secret env and create the a new JWT signing key in the corresponding Supabase env. To do that:
1. Find the current value of the secret used by Agicash Next Open Secret env (ask other devs to share it)
2. Go to [Supabase dashboard](https://supabase.com/dashboard/) and in the JWT Keys section of the corresponding Supabase project settings create a new Standby Key. Pick `HS256 (Shared Secret)` signing algorithm, select to import existing secret and paste the secret there. Make sure not to select `Secret is already Base64 encoded`. Once the key is created, then rotate the keys to make the new one used by Supabase.

### Configuring a new Open Secret environment

If there is a new Agicash app environment that needs to have a dedicated Open Secret environment, create a new project in [Open Secret cloud dashboard](https://app.opensecret.cloud/orgs/a92d07fb-3837-42e0-9a83-f109c425391e/projects). Things that need to be configured are:
1. Google OAuth settings - Open Secret needs Google Auth client id and secret. You can get those in the [Google Cloud Console](https://console.cloud.google.com/auth/clients) from the existing client or create a new one.
2. Resend email settings - Open Secret uses [Resend](https://resend.com/) platform to send emails and needs the API key. You can create the API key [here](https://resend.com/api-keys). Additionally you need to configure the email to send from (e.g. noreply@agi.cash) and URL for email verification link (when user receives an email for email address
verification, the email will contain this link).
3. Third-Party JWT Secret - Open Secret creates JWTs that are then used by Supabase to authorize if the user should have access to the requested data. For this to work, Open Secret and Supabase need to share the secret that signed the JWT. To create a secret, go to [Supabase dashboard](https://supabase.com/dashboard/) and in the JWT Keys section of the corresponding Supabase project settings create a new Standby Key (pick `HS256 (Shared Secret)` signing algorithm) and then rotate the keys to make newly created one used by Supabase. Once that is done, paste the same secret into Open Secret dashboard.

Lastly we need to point the Agicash app to the new Open Secret environment. To do that set `VITE_OPEN_SECRET_CLIENT_ID` env variable to the client ID of the created Open Secret project.

## Dependencies

A dependency should be added only if the benefits are clear. Avoid adding it for trivial stuff. Any dependency added
Expand Down Expand Up @@ -190,4 +214,29 @@ New e2e test suites should be added to the `e2e` folder and named `<name_of_the_

Every pull request created will trigger the GitHub Actions CI pipeline. The pipeline runs three jobs in parallel. One
checks code format, lint, and types. Another runs the unit tests, and a third one runs e2e tests. If any of the jobs fail,
merging to `master` will not be allowed.
merging to `master` will not be allowed.

## Gift Card Assets

Gift card images should use the **WebP format** for better compression and faster loading.

### Converting PNG to WebP

Use the provided conversion script:

```sh
# Convert a single file
./tools/convert-to-webp.sh app/assets/gift-cards/mycard.png

# Convert all PNGs in the gift-cards directory
./tools/convert-to-webp.sh --dir app/assets/gift-cards

# With custom quality (default is 80)
./tools/convert-to-webp.sh -q 85 --dir app/assets/gift-cards
```

Requires `cwebp` - install with `brew install webp` or run via nix:

```sh
nix shell nixpkgs#libwebp --command ./tools/convert-to-webp.sh --dir app/assets/gift-cards
```
Binary file added app/assets/gift-cards/blockandbean.agi.cash.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added app/assets/gift-cards/compass.agi.cash.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added app/assets/gift-cards/fake.agi.cash.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added app/assets/gift-cards/fake4.agi.cash.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added app/assets/gift-cards/pinkowl.agi.cash.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added app/assets/gift-cards/shack.agi.cash.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
6 changes: 6 additions & 0 deletions app/components/money-display.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,9 @@ const textVariants = cva('', {
muted: 'text-muted-foreground',
},
size: {
xs: 'font-semibold',
sm: 'font-semibold',
md: 'font-bold',
lg: 'font-bold',
},
},
Expand All @@ -23,7 +25,9 @@ const textVariants = cva('', {
const symbolVariants = cva('', {
variants: {
size: {
xs: 'text-[1.1rem]',
sm: 'text-[1.33rem]',
md: 'text-[2.85rem]',
lg: 'text-[3.45rem]',
},
},
Expand All @@ -35,7 +39,9 @@ const symbolVariants = cva('', {
const valueVariants = cva('font-numeric', {
variants: {
size: {
xs: 'pt-0.5 text-xl',
sm: 'pt-1 text-2xl',
md: 'pt-1.5 text-5xl',
lg: 'pt-2 text-6xl',
},
},
Expand Down
163 changes: 110 additions & 53 deletions app/components/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import {
} from '~/lib/transitions';
import { cn } from '~/lib/utils';

export type PageHeaderPosition = 'left' | 'center' | 'right';

interface PageProps extends React.HTMLAttributes<HTMLDivElement> {
children: React.ReactNode;
}
Expand All @@ -24,95 +26,150 @@ export function Page({ children, className, ...props }: PageProps) {
);
}

interface ClosePageButtonProps extends ViewTransitionLinkProps {}
type PageHeaderItemProps = React.HTMLAttributes<HTMLDivElement> & {
children: React.ReactNode;
position: PageHeaderPosition;
};

export function ClosePageButton({ className, ...props }: ClosePageButtonProps) {
export function PageHeaderItem({
children,
position,
className,
...props
}: PageHeaderItemProps) {
return (
<LinkWithViewTransition {...props}>
<X />
</LinkWithViewTransition>
<div className={className} {...props}>
{children}
</div>
);
}
PageHeaderItem.isHeaderItem = true;
PageHeaderItem.defaultPosition = undefined as PageHeaderPosition | undefined;

export interface PageBackButtonProps extends ViewTransitionLinkProps {}
type ClosePageButtonProps = ViewTransitionLinkProps & {
position?: PageHeaderPosition;
};

export function PageBackButton({ className, ...props }: PageBackButtonProps) {
/**
* @default position - 'left'
*/
export function ClosePageButton({
className,
position = 'left',
...props
}: ClosePageButtonProps) {
return (
<LinkWithViewTransition {...props}>
<ChevronLeft />
</LinkWithViewTransition>
<PageHeaderItem position={position}>
<LinkWithViewTransition {...props}>
<X />
</LinkWithViewTransition>
</PageHeaderItem>
);
}
ClosePageButton.isHeaderItem = true;
ClosePageButton.defaultPosition = 'left' as PageHeaderPosition;

interface PageHeaderTitleProps extends React.HTMLAttributes<HTMLDivElement> {
children: React.ReactNode;
export type PageBackButtonProps = ViewTransitionLinkProps & {
position?: PageHeaderPosition;
};

/**
* @default position - 'left'
*/
export function PageBackButton({
className,
position = 'left',
...props
}: PageBackButtonProps) {
return (
<PageHeaderItem position={position}>
<LinkWithViewTransition {...props}>
<ChevronLeft />
</LinkWithViewTransition>
</PageHeaderItem>
);
}
PageBackButton.isHeaderItem = true;
PageBackButton.defaultPosition = 'left' as PageHeaderPosition;

type PageHeaderTitleProps = React.HTMLAttributes<HTMLHeadingElement> & {
children: React.ReactNode;
position?: PageHeaderPosition;
};

/**
* @default position - 'center'
*/
export function PageHeaderTitle({
children,
className,
position = 'center',
...props
}: PageHeaderTitleProps) {
return (
<h1
className={cn('flex items-center justify-start text-xl', className)}
{...props}
>
{children}
</h1>
<PageHeaderItem position={position}>
<h1
className={cn('flex items-center justify-start text-xl', className)}
{...props}
>
{children}
</h1>
</PageHeaderItem>
);
}
PageHeaderTitle.isHeaderItem = true;
PageHeaderTitle.defaultPosition = 'center' as PageHeaderPosition;

interface PageHeaderProps extends React.HTMLAttributes<HTMLDivElement> {
type PageHeaderProps = React.HTMLAttributes<HTMLDivElement> & {
children: React.ReactNode;
}
};

export function PageHeader({ children, className, ...props }: PageHeaderProps) {
const hasCloseButton = React.Children.toArray(children).some(
(child) => React.isValidElement(child) && child.type === ClosePageButton,
);
const hasBackButton = React.Children.toArray(children).some(
(child) => React.isValidElement(child) && child.type === PageBackButton,
const isPageHeaderItem = (
child: React.ReactNode,
): child is React.ReactElement<{ position?: PageHeaderPosition }> => {
return (
React.isValidElement(child) &&
typeof child.type !== 'string' &&
'isHeaderItem' in child.type &&
(child.type as { isHeaderItem?: boolean }).isHeaderItem === true
);
};

if (hasCloseButton && hasBackButton) {
export function PageHeader({ children, className, ...props }: PageHeaderProps) {
const childrenArray = React.Children.toArray(children);

if (childrenArray.length === 0 || !childrenArray.every(isPageHeaderItem)) {
throw new Error(
'PageHeader cannot have both ClosePageButton and BackButton',
'PageHeader children must be a component that is marked with isHeaderItem = true',
);
}

const getChildrenByPosition = (pos: PageHeaderPosition) => {
return childrenArray.filter((child) => {
if (!React.isValidElement(child)) return false;
const props = child.props as { position?: PageHeaderPosition };
const componentType = child.type as {
defaultPosition?: PageHeaderPosition;
};
const position = props.position ?? componentType.defaultPosition;
return position === pos;
});
};

const leftItems = getChildrenByPosition('left');
const centerItems = getChildrenByPosition('center');
const rightItems = getChildrenByPosition('right');

return (
<header
className={cn('mb-4 flex w-full items-center justify-between', className)}
{...props}
>
{/* Close/back button - always on the left */}
<div>
{React.Children.toArray(children).find(
(child) =>
React.isValidElement(child) &&
(child.type === ClosePageButton || child.type === PageBackButton),
)}
</div>

{/* Title - always in the center */}
<div className="flex items-center">{leftItems}</div>
<div className="-translate-x-1/2 absolute left-1/2 transform">
{React.Children.toArray(children).find(
(child) =>
React.isValidElement(child) && child.type === PageHeaderTitle,
)}
</div>

{/* Other elements - on the right */}
<div className="flex items-center justify-end gap-2">
{React.Children.toArray(children).filter(
(child) =>
!React.isValidElement(child) ||
(child.type !== PageHeaderTitle &&
child.type !== ClosePageButton &&
child.type !== PageBackButton),
)}
{centerItems}
</div>
<div className="flex items-center justify-end gap-2">{rightItems}</div>
</header>
);
}
Expand Down
Loading