Skip to content

Commit 358939a

Browse files
committed
test: fix tests
2 parents 65e137b + 08981ab commit 358939a

39 files changed

Lines changed: 2108 additions & 604 deletions

.env.example

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,10 @@ CLERK_SECRET_KEY=sk_test_cZI9RwUcgLMfd6HPsQgX898hSthNjnNGKRcaVGvUCK
66
NEXT_PUBLIC_ZENAO_BACKEND_ENDPOINT=http://localhost:4242
77
NEXT_PUBLIC_ZENAO_NAMESPACE=zenao
88

9+
# Stripe configuration
10+
ZENAO_STRIPE_SECRET_KEY=sk_test_...
11+
NEXT_PUBLIC_STRIPE_DASHBOARD_URL=https://dashboard.stripe.com/test
12+
913
# File uploads - See README "File Uploads with Pinata" section
1014
NEXT_PUBLIC_GATEWAY_URL=pinata.zenao.io
1115
PINATA_GROUP=f2ecce4d-b615-48ee-8ae8-744145b40dcb # Optional: for organizing files in Pinata dashboard
@@ -15,3 +19,5 @@ PINATA_JWT= # Required for uploading images (e.g., to create events)
1519
OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318
1620
SEOBOT_API_KEY=a8c58738-7b98-4597-b20a-0bb1c2fe5772
1721

22+
# App URL for backend
23+
ZENAO_APP_BASE_URL=http://localhost:3000/

README.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,9 @@ CLERK_SECRET_KEY=""
147147
NEXT_PUBLIC_ZENAO_BACKEND_ENDPOINT=http://localhost:4242
148148
NEXT_PUBLIC_ZENAO_NAMESPACE=zenao
149149
150+
# Stripe configuration
151+
NEXT_PUBLIC_STRIPE_DASHBOARD_URL=https://dashboard.stripe.com/test
152+
150153
# File uploads - See README "File Uploads with Pinata" section
151154
NEXT_PUBLIC_GATEWAY_URL=pinata.zenao.io
152155
PINATA_GROUP=f2ecce4d-b615-48ee-8ae8-744145b40dcb # Optional: for organizing files in Pinata dashboard
@@ -170,6 +173,8 @@ ZENAO_DB=dev.db # Default: dev.db
170173
ZENAO_ALLOWED_ORIGINS=* # Default: * (all origins)
171174
ZENAO_MAIL_SENDER=contact@mail.zenao.io # Default: contact@mail.zenao.io
172175
ZENAO_RESEND_SECRET_KEY= # Default: empty (emails disabled)
176+
ZENAO_STRIPE_SECRET_KEY= # Default: empty (stripe disabled)
177+
ZENAO_APP_BASE_URL= # Default: https://zenao.io/
173178
DISCORD_TOKEN= # Default: empty (Discord disabled)
174179
```
175180

api/zenao/v1/zenao.proto

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,10 @@ service ZenaoService {
3232
rpc CreateCommunity(CreateCommunityRequest)
3333
returns (CreateCommunityResponse);
3434
rpc EditCommunity(EditCommunityRequest) returns (EditCommunityResponse);
35+
rpc StartCommunityStripeOnboarding(StartCommunityStripeOnboardingRequest)
36+
returns (StartCommunityStripeOnboardingResponse);
37+
rpc GetCommunityPayoutStatus(GetCommunityPayoutStatusRequest)
38+
returns (GetCommunityPayoutStatusResponse);
3539
rpc GetCommunityAdministrators(GetCommunityAdministratorsRequest)
3640
returns (GetCommunityAdministratorsResponse);
3741
rpc JoinCommunity(JoinCommunityRequest) returns (JoinCommunityResponse);
@@ -522,6 +526,27 @@ message EditCommunityRequest {
522526

523527
message EditCommunityResponse {}
524528

529+
message StartCommunityStripeOnboardingRequest {
530+
string community_id = 1;
531+
string return_path = 2;
532+
string refresh_path = 3;
533+
}
534+
535+
message StartCommunityStripeOnboardingResponse { string onboarding_url = 1; }
536+
537+
message GetCommunityPayoutStatusRequest {
538+
string community_id = 1;
539+
}
540+
541+
message GetCommunityPayoutStatusResponse {
542+
string verification_state = 1;
543+
int64 last_verified_at = 2;
544+
bool is_stale = 3;
545+
string refresh_error = 4;
546+
string onboarding_state = 5;
547+
string platform_account_id = 6;
548+
}
549+
525550
message CreateTeamRequest { string display_name = 1; }
526551

527552
message CreateTeamResponse { string team_id = 1; }
@@ -580,4 +605,4 @@ message RemoveEventFromCommunityRequest {
580605
string event_id = 2;
581606
}
582607

583-
message RemoveEventFromCommunityResponse {}
608+
message RemoveEventFromCommunityResponse {}

app/(dashboard)/dashboard/community/[id]/dashboard-community-tabs.tsx

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,17 @@ export default function DashboardCommunityTabs({
6868
{t("events")}
6969
</TabsTrigger>
7070
</Link>
71+
<Link
72+
href={`/dashboard/community/${communityId}/payouts`}
73+
scroll={false}
74+
>
75+
<TabsTrigger
76+
value="payouts"
77+
className="w-fit p-2 data-[state=active]:font-semibold hover:bg-secondary/80"
78+
>
79+
{t("payouts")}
80+
</TabsTrigger>
81+
</Link>
7182
</TabsList>
7283
<Separator className="mb-3" />
7384

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
import PayoutsConfiguration from "./payouts-configuration";
2+
3+
export default function DashboardCommunityPayoutsPage() {
4+
return <PayoutsConfiguration />;
5+
}
Lines changed: 212 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,212 @@
1+
"use client";
2+
3+
import { useEffect, useRef } from "react";
4+
import { useTranslations } from "next-intl";
5+
import { useAuth } from "@clerk/nextjs";
6+
import { usePathname, useRouter, useSearchParams } from "next/navigation";
7+
import { useQuery, useSuspenseQuery } from "@tanstack/react-query";
8+
import { useToast } from "@/hooks/use-toast";
9+
import { useStartCommunityStripeOnboarding } from "@/lib/mutations/stripe-onboarding";
10+
import { captureException } from "@/lib/report";
11+
import {
12+
communityPayoutStatus,
13+
communityUserRoles,
14+
} from "@/lib/queries/community";
15+
import { userInfoOptions } from "@/lib/queries/user";
16+
import SettingsSection from "@/components/layout/settings-section";
17+
import { Card } from "@/components/widgets/cards/card";
18+
import Heading from "@/components/widgets/texts/heading";
19+
import { Badge } from "@/components/shadcn/badge";
20+
import { Button } from "@/components/shadcn/button";
21+
import { DateTimeText } from "@/components/widgets/date-time-text";
22+
import { useDashboardCommunityContext } from "@/components/providers/dashboard-community-context-provider";
23+
24+
const payoutStatusBadge = {
25+
verified: "secondary",
26+
failed: "destructive",
27+
pending: "outline",
28+
unknown: "outline",
29+
} as const;
30+
31+
export type PayoutStatus = keyof typeof payoutStatusBadge;
32+
33+
export default function PayoutsConfiguration() {
34+
const { communityId } = useDashboardCommunityContext();
35+
const { getToken, userId } = useAuth();
36+
const { toast } = useToast();
37+
const router = useRouter();
38+
const pathname = usePathname();
39+
const searchParams = useSearchParams();
40+
const handledStripeParams = useRef(false);
41+
const t = useTranslations("community-form");
42+
const tEdit = useTranslations("community-edit-form");
43+
44+
const { data: userInfo } = useSuspenseQuery(
45+
userInfoOptions(getToken, userId),
46+
);
47+
const { data: userRoles = [] } = useSuspenseQuery(
48+
communityUserRoles(communityId, userInfo?.userId || ""),
49+
);
50+
const isAdmin = userRoles.includes("administrator");
51+
52+
const { data: payoutStatus, isLoading: isPayoutStatusLoading } = useQuery({
53+
...communityPayoutStatus(communityId, getToken),
54+
enabled: isAdmin,
55+
});
56+
const {
57+
mutateAsync: startCommunityStripeOnboarding,
58+
isPending: isStripeOnboardingPending,
59+
} = useStartCommunityStripeOnboarding();
60+
61+
useEffect(() => {
62+
if (handledStripeParams.current) return;
63+
const stripeParam = searchParams.get("stripe");
64+
if (!stripeParam) return;
65+
66+
handledStripeParams.current = true;
67+
if (stripeParam === "return") {
68+
toast({
69+
title: tEdit("stripe-onboarding-return"),
70+
variant: "default",
71+
});
72+
} else if (stripeParam === "refresh") {
73+
toast({
74+
variant: "destructive",
75+
title: tEdit("stripe-onboarding-refresh"),
76+
});
77+
}
78+
router.replace(pathname);
79+
}, [pathname, router, searchParams, tEdit, toast]);
80+
81+
const handleStartCommunityStripeOnboarding = async () => {
82+
try {
83+
const token = await getToken();
84+
if (!token) throw new Error("invalid clerk token");
85+
86+
const returnPath = `/dashboard/community/${communityId}/payouts?stripe=return`;
87+
const refreshPath = `/dashboard/community/${communityId}/payouts?stripe=refresh`;
88+
89+
const onboardingUrl = await startCommunityStripeOnboarding({
90+
token,
91+
communityId,
92+
returnPath,
93+
refreshPath,
94+
});
95+
96+
if (!onboardingUrl) {
97+
throw new Error("missing onboarding url");
98+
}
99+
100+
window.location.href = onboardingUrl;
101+
} catch (err) {
102+
captureException(err);
103+
toast({
104+
variant: "destructive",
105+
title: tEdit("stripe-onboarding-failure"),
106+
});
107+
}
108+
};
109+
110+
if (!isAdmin) {
111+
return null;
112+
}
113+
114+
const statusMissingAccount = "missing_account";
115+
const lastVerifiedAtSeconds = payoutStatus?.lastVerifiedAt
116+
? Number(payoutStatus.lastVerifiedAt)
117+
: 0;
118+
const hasLastVerifiedAt =
119+
payoutStatus?.lastVerifiedAt != null && lastVerifiedAtSeconds > 0;
120+
const isOnboardingComplete =
121+
payoutStatus?.onboardingState === "completed" ||
122+
payoutStatus?.verificationState === "verified";
123+
const isMissingStripeAccountId =
124+
isOnboardingComplete && !payoutStatus?.platformAccountId;
125+
const isStripeAccountMissingError =
126+
payoutStatus?.verificationState === statusMissingAccount;
127+
const shouldShowRefreshError =
128+
!!payoutStatus?.refreshError && !isStripeAccountMissingError;
129+
const payoutStatusLabel = (payoutStatus?.verificationState ??
130+
"unknown") as PayoutStatus;
131+
const payoutStatusText: Record<PayoutStatus, string> = {
132+
verified: t("payout-status-verified"),
133+
failed: t("payout-status-failed"),
134+
pending: t("payout-status-pending"),
135+
unknown: t("payout-status-unknown"),
136+
};
137+
const payoutStatusKey = payoutStatusText[payoutStatusLabel ?? ""]
138+
? payoutStatusLabel
139+
: "unknown";
140+
141+
return (
142+
<SettingsSection
143+
title={t("payments-section")}
144+
description={t("payments-description")}
145+
>
146+
<Card className="p-6">
147+
<div className="flex flex-col gap-2">
148+
<Heading level={4}>{t("stripe-connect-label")}</Heading>
149+
<p className="text-sm text-muted-foreground">
150+
{t("stripe-connect-description")}
151+
</p>
152+
<div className="pt-2">
153+
<div className="flex items-center gap-2">
154+
<Heading level={5}>{t("payout-status-label")}</Heading>
155+
{isPayoutStatusLoading || payoutStatusKey === undefined ? (
156+
<Badge variant="outline">{t("payout-status-loading")}</Badge>
157+
) : (
158+
<Badge
159+
variant={
160+
payoutStatusBadge[payoutStatusKey as PayoutStatus] ??
161+
"outline"
162+
}
163+
>
164+
{payoutStatusText[payoutStatusKey]}
165+
</Badge>
166+
)}
167+
</div>
168+
<div className="flex flex-col gap-1 text-sm text-muted-foreground pt-2">
169+
<span>{t("payout-status-last-checked")}</span>
170+
{hasLastVerifiedAt ? (
171+
<DateTimeText datetime={lastVerifiedAtSeconds} />
172+
) : (
173+
<span>{t("payout-status-never")}</span>
174+
)}
175+
{payoutStatus?.isStale && <span>{t("payout-status-stale")}</span>}
176+
{shouldShowRefreshError && (
177+
<span>{t("payout-status-refresh-error")}</span>
178+
)}
179+
{(isMissingStripeAccountId || isStripeAccountMissingError) && (
180+
<span>{t("stripe-dashboard-missing-account")}</span>
181+
)}
182+
</div>
183+
</div>
184+
<div className="pt-2">
185+
<Button
186+
type="button"
187+
onClick={() => {
188+
if (isOnboardingComplete) {
189+
window.open(
190+
process.env.NEXT_PUBLIC_STRIPE_DASHBOARD_URL,
191+
"_blank",
192+
);
193+
return;
194+
}
195+
void handleStartCommunityStripeOnboarding();
196+
}}
197+
disabled={
198+
isStripeOnboardingPending ||
199+
isPayoutStatusLoading ||
200+
isMissingStripeAccountId
201+
}
202+
>
203+
{isOnboardingComplete
204+
? t("stripe-dashboard-cta")
205+
: t("stripe-connect-cta")}
206+
</Button>
207+
</div>
208+
</div>
209+
</Card>
210+
</SettingsSection>
211+
);
212+
}

app/(general)/community/(settings)/create/create-community-form.tsx renamed to app/(dashboard)/dashboard/community/create/create-community-form.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ export default function CreateCommunityForm({
4242
communityFormSchema.extend({
4343
administrators: z.array(
4444
z.object({
45-
address: z
45+
email: z
4646
.string()
4747
.email("Administrator must be a valid email address"),
4848
}),

app/(dashboard)/dashboard/community/create/page.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,13 @@
11
import { dehydrate, HydrationBoundary } from "@tanstack/react-query";
22
import { getTranslations } from "next-intl/server";
33
import { auth } from "@clerk/nextjs/server";
4+
import CreateCommunityForm from "./create-community-form";
45
import { getQueryClient } from "@/lib/get-query-client";
56
import { userInfoOptions } from "@/lib/queries/user";
67
import {
78
ScreenContainer,
89
ScreenContainerCentered,
910
} from "@/components/layout/screen-container";
10-
import CreateCommunityForm from "@/app/(general)/community/(settings)/create/create-community-form";
1111

1212
export default async function CreateCommunityPage() {
1313
const queryClient = getQueryClient();

0 commit comments

Comments
 (0)