diff --git a/api/api.go b/api/api.go
index edaff3e5..924f286f 100644
--- a/api/api.go
+++ b/api/api.go
@@ -13,6 +13,7 @@ import (
"time"
"github.com/sirupsen/logrus"
+ "gorm.io/datatypes"
"gorm.io/gorm"
"github.com/getAlby/hub/alby"
@@ -141,6 +142,20 @@ func (api *api) UpdateApp(userApp *db.App, updateAppRequest *UpdateAppRequest) e
}
}
+ if updateAppRequest.Metadata != nil {
+ var metadataBytes []byte
+ var err error
+ metadataBytes, err = json.Marshal(updateAppRequest.Metadata)
+ if err != nil {
+ logger.Logger.WithError(err).Error("Failed to serialize metadata")
+ return err
+ }
+ err = tx.Model(&db.App{}).Where("id", userApp.ID).Update("metadata", datatypes.JSON(metadataBytes)).Error
+ if err != nil {
+ return err
+ }
+ }
+
// Update existing permissions with new budget and expiry
err := tx.Model(&db.AppPermission{}).Where("app_id", userApp.ID).Updates(map[string]interface{}{
"ExpiresAt": expiresAt,
diff --git a/api/models.go b/api/models.go
index fd01e69d..c6c72f55 100644
--- a/api/models.go
+++ b/api/models.go
@@ -83,6 +83,7 @@ type UpdateAppRequest struct {
BudgetRenewal string `json:"budgetRenewal"`
ExpiresAt string `json:"expiresAt"`
Scopes []string `json:"scopes"`
+ Metadata Metadata `json:"metadata,omitempty"`
}
type CreateAppRequest struct {
diff --git a/frontend/src/assets/suggested-apps/buzzpay.png b/frontend/src/assets/suggested-apps/buzzpay.png
new file mode 100644
index 00000000..81988f89
Binary files /dev/null and b/frontend/src/assets/suggested-apps/buzzpay.png differ
diff --git a/frontend/src/components/SuggestedAppData.tsx b/frontend/src/components/SuggestedAppData.tsx
index 083f8905..49b67f93 100644
--- a/frontend/src/components/SuggestedAppData.tsx
+++ b/frontend/src/components/SuggestedAppData.tsx
@@ -1,5 +1,6 @@
import alby from "src/assets/suggested-apps/alby.png";
import amethyst from "src/assets/suggested-apps/amethyst.png";
+import buzzpay from "src/assets/suggested-apps/buzzpay.png";
import damus from "src/assets/suggested-apps/damus.png";
import hablanews from "src/assets/suggested-apps/habla-news.png";
import kiwi from "src/assets/suggested-apps/kiwi.png";
@@ -38,6 +39,13 @@ export const suggestedApps: SuggestedApp[] = [
internal: true,
logo: uncleJim,
},
+ {
+ id: "buzzpay",
+ title: "BuzzPay PoS",
+ description: "Receive-only PoS you can safely share with your employees",
+ internal: true,
+ logo: buzzpay,
+ },
{
id: "alby-extension",
title: "Alby Extension",
diff --git a/frontend/src/routes.tsx b/frontend/src/routes.tsx
index 843a0439..bd842876 100644
--- a/frontend/src/routes.tsx
+++ b/frontend/src/routes.tsx
@@ -33,7 +33,8 @@ import { OpeningAutoChannel } from "src/screens/channels/auto/OpeningAutoChannel
import { FirstChannel } from "src/screens/channels/first/FirstChannel";
import { OpenedFirstChannel } from "src/screens/channels/first/OpenedFirstChannel";
import { OpeningFirstChannel } from "src/screens/channels/first/OpeningFirstChannel";
-import { UncleJimApp } from "src/screens/internal-apps/UncleJimApp";
+import { BuzzPay } from "src/screens/internal-apps/BuzzPay";
+import { UncleJim } from "src/screens/internal-apps/UncleJim";
import { Success } from "src/screens/onboarding/Success";
import BuyBitcoin from "src/screens/onchain/BuyBitcoin";
import DepositBitcoin from "src/screens/onchain/DepositBitcoin";
@@ -210,7 +211,11 @@ const routes = [
children: [
{
path: "uncle-jim",
- element: ,
+ element: ,
+ },
+ {
+ path: "buzzpay",
+ element: ,
},
],
},
diff --git a/frontend/src/screens/apps/ShowApp.tsx b/frontend/src/screens/apps/ShowApp.tsx
index 0ebc8c5e..68dc8a09 100644
--- a/frontend/src/screens/apps/ShowApp.tsx
+++ b/frontend/src/screens/apps/ShowApp.tsx
@@ -7,7 +7,6 @@ import { useDeleteApp } from "src/hooks/useDeleteApp";
import {
App,
AppPermissions,
- BudgetRenewalType,
UpdateAppRequest,
WalletCapabilities,
} from "src/types";
@@ -95,7 +94,7 @@ function AppInternal({ app, refetchApp, capabilities }: AppInternalProps) {
const [permissions, setPermissions] = React.useState({
scopes: app.scopes,
maxAmount: app.maxAmount,
- budgetRenewal: app.budgetRenewal as BudgetRenewalType,
+ budgetRenewal: app.budgetRenewal,
expiresAt: app.expiresAt ? new Date(app.expiresAt) : undefined,
isolated: app.isolated,
});
diff --git a/frontend/src/screens/internal-apps/BuzzPay.tsx b/frontend/src/screens/internal-apps/BuzzPay.tsx
new file mode 100644
index 00000000..3d511790
--- /dev/null
+++ b/frontend/src/screens/internal-apps/BuzzPay.tsx
@@ -0,0 +1,145 @@
+import React from "react";
+import AppHeader from "src/components/AppHeader";
+import AppCard from "src/components/connections/AppCard";
+import Loading from "src/components/Loading";
+import { ExternalLinkButton } from "src/components/ui/button";
+import { LoadingButton } from "src/components/ui/loading-button";
+import { useToast } from "src/components/ui/use-toast";
+import { useApps } from "src/hooks/useApps";
+import {
+ App,
+ AppPermissions,
+ CreateAppRequest,
+ CreateAppResponse,
+ UpdateAppRequest,
+} from "src/types";
+import { handleRequestError } from "src/utils/handleRequestError";
+import { request } from "src/utils/request";
+
+export function BuzzPay() {
+ const { data: apps, mutate: reloadApps } = useApps();
+ const [creatingApp, setCreatingApp] = React.useState(false);
+ const { toast } = useToast();
+
+ if (!apps) {
+ return ;
+ }
+ const app = apps.find(
+ (app) =>
+ app.metadata?.app_store_app_id === "buzzpay" &&
+ app.metadata.connection_secret
+ );
+
+ function createApp() {
+ setCreatingApp(true);
+ (async () => {
+ try {
+ const name = "BuzzPay";
+ if (apps?.some((existingApp) => existingApp.name === name)) {
+ throw new Error("A connection with the same name already exists.");
+ }
+
+ const createAppRequest: CreateAppRequest = {
+ name,
+ scopes: ["get_info", "lookup_invoice", "make_invoice"],
+ isolated: false,
+ metadata: {
+ app_store_app_id: "buzzpay",
+ },
+ };
+
+ const createAppResponse = await request(
+ "/api/apps",
+ {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify(createAppRequest),
+ }
+ );
+
+ if (!createAppResponse) {
+ throw new Error("no create app response received");
+ }
+
+ const app = await request(
+ `/api/apps/${createAppResponse.pairingPublicKey}`
+ );
+
+ if (!app) {
+ throw new Error("failed to fetch buzzpay app");
+ }
+
+ const permissions: AppPermissions = {
+ scopes: app.scopes,
+ maxAmount: app.maxAmount,
+ budgetRenewal: app.budgetRenewal,
+ expiresAt: app.expiresAt ? new Date(app.expiresAt) : undefined,
+ isolated: app.isolated,
+ };
+
+ // TODO: should be able to partially update app rather than having to pass everything
+ // we are only updating the metadata
+ const updateAppRequest: UpdateAppRequest = {
+ name,
+ scopes: Array.from(permissions.scopes),
+ budgetRenewal: permissions.budgetRenewal,
+ expiresAt: permissions.expiresAt?.toISOString(),
+ maxAmount: permissions.maxAmount,
+ metadata: {
+ ...app.metadata,
+ // read-only connection secret
+ connection_secret: createAppResponse.pairingUri,
+ },
+ };
+
+ await request(`/api/apps/${app.nostrPubkey}`, {
+ method: "PATCH",
+ headers: {
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify(updateAppRequest),
+ });
+ await reloadApps();
+
+ toast({ title: "BuzzPay app created" });
+ } catch (error) {
+ handleRequestError(toast, "Failed to create app", error);
+ }
+ setCreatingApp(false);
+ })();
+ }
+
+ return (
+
+
+ {app && (
+
+
+
+ Go to BuzzPay PoS
+
+
+ )}
+ {!app && (
+
+
+ By creating a new buzzpay app, a read-only wallet connection will be
+ created and you will receive a link you can share with your
+ employees, on any device.
+
+
+ Create BuzzPay App
+
+
+ )}
+
+ );
+}
diff --git a/frontend/src/screens/internal-apps/UncleJimApp.tsx b/frontend/src/screens/internal-apps/UncleJim.tsx
similarity index 99%
rename from frontend/src/screens/internal-apps/UncleJimApp.tsx
rename to frontend/src/screens/internal-apps/UncleJim.tsx
index 93d26d9e..31e6cee1 100644
--- a/frontend/src/screens/internal-apps/UncleJimApp.tsx
+++ b/frontend/src/screens/internal-apps/UncleJim.tsx
@@ -24,7 +24,7 @@ import { CreateAppRequest, CreateAppResponse } from "src/types";
import { handleRequestError } from "src/utils/handleRequestError";
import { request } from "src/utils/request";
-export function UncleJimApp() {
+export function UncleJim() {
const [name, setName] = React.useState("");
const [appPublicKey, setAppPublicKey] = React.useState("");
const [connectionSecret, setConnectionSecret] = React.useState("");
diff --git a/frontend/src/types.ts b/frontend/src/types.ts
index b060cae9..d4d50dfb 100644
--- a/frontend/src/types.ts
+++ b/frontend/src/types.ts
@@ -152,9 +152,10 @@ export interface InfoResponse {
export type Network = "bitcoin" | "testnet" | "signet";
-export type AppMetadata =
- | Record
- | { app_store_app_id?: string };
+export type AppMetadata = { app_store_app_id?: string } & Record<
+ string,
+ unknown
+>;
export interface MnemonicResponse {
mnemonic: string;
@@ -187,6 +188,7 @@ export type UpdateAppRequest = {
budgetRenewal: string;
expiresAt: string | undefined;
scopes: Scope[];
+ metadata?: AppMetadata;
};
export type Channel = {