| 1 | "use client"; |
| 2 | |
| 3 | import { use, useState |
| 4 | import Link from "next/link |
| 5 | import { useRouter } |
| 6 | import { useMutation, useQuery |
| 7 | import { useTranslations } |
| 8 | import { toast } |
| 9 | import { |
| 10 | ChevronLeft, |
| 11 | ChevronRight, |
| 12 | Check, |
| 13 | Layers, |
| 14 | Tag, |
| 15 | Star, |
| 16 | Cpu, |
| 17 | MemoryStick, |
| 18 | HardDrive, |
| 19 | Archive, |
| 20 | Network, |
| 21 | Database, |
| 22 | Server, |
| 23 | } from "lucide-react"; |
| 24 | import { orpc } |
| 25 | import { cn } |
| 26 | import { Button } |
| 27 | import { Input } |
| 28 | import { Dialog, DialogPopup |
| 29 | import { Sheet, SheetPopup |
| 30 | import { useMediaQuery } |
| 31 | |
| 32 | type Duration |
| 33 | |
| 34 | interface PlanPrice { |
| 35 | id: string; |
| 36 | duration: Duration |
| 37 | price: number; |
| 38 | } |
| 39 | |
| 40 | interface ResourceLimits { |
| 41 | cpu: number; |
| 42 | ram: number; |
| 43 | disk: number; |
| 44 | backups: number; |
| 45 | allocations: number; |
| 46 | databases: number; |
| 47 | eggs: string[] |
| 48 | } |
| 49 | |
| 50 | function formatPrice(price: |
| 51 | try { |
| 52 | return new Intl |
| 53 | style: "currency", |
| 54 | currency, |
| 55 | minimumFractionDigits: 2, |
| 56 | maximumFractionDigits: 2, |
| 57 | }).format(price |
| 58 | } catch { |
| 59 | return price.toFixed |
| 60 | } |
| 61 | } |
| 62 | |
| 63 | const DURATION_ORDER: Duration |
| 64 | "7day", |
| 65 | "1month", |
| 66 | "3months", |
| 67 | "6months", |
| 68 | "1year", |
| 69 | ]; |
| 70 | |
| 71 | function PurchaseDialog({ |
| 72 | open, |
| 73 | onClose, |
| 74 | planId, |
| 75 | planName, |
| 76 | planDescription, |
| 77 | planIconUrl, |
| 78 | resources, |
| 79 | prices, |
| 80 | currency, |
| 81 | walletBalanceCents, |
| 82 | onPurchase, |
| 83 | isPurchasing, |
| 84 | }: { |
| 85 | open: boolean |
| 86 | onClose: () |
| 87 | planId: string |
| 88 | planName: string |
| 89 | planDescription: string |
| 90 | planIconUrl: string |
| 91 | resources: ResourceLimits |
| 92 | prices: PlanPrice |
| 93 | currency: string |
| 94 | walletBalanceCents: number |
| 95 | onPurchase: ( |
| 96 | priceId: string |
| 97 | eggId: string |
| 98 | serverName: string |
| 99 | dockerImage: string |
| 100 | envVars: Record |
| 101 | ) => |
| 102 | isPurchasing: boolean |
| 103 | }) { |
| 104 | const t = |
| 105 | const d = |
| 106 | |
| 107 | const sortedPrices = |
| 108 | (a, b) |
| 109 | DURATION_ORDER.indexOf(a |
| 110 | ); |
| 111 | |
| 112 | const [step, |
| 113 | const [selectedPriceId, |
| 114 | sortedPrices[0]?.id |
| 115 | ); |
| 116 | const [selectedEggId, |
| 117 | const [selectedDockerImage, |
| 118 | const [envVarValues, |
| 119 | const [serverName, |
| 120 | const [showAllImages, |
| 121 | |
| 122 | const isMobile = |
| 123 | |
| 124 | const { data: |
| 125 | ...orpc.billing. |
| 126 | enabled: open && |
| 127 | }); |
| 128 | |
| 129 | const selectedPrice = |
| 130 | const selectedEgg = |
| 131 | const canAfford = |
| 132 | walletBalanceCents >= (selectedPrice |
| 133 | |
| 134 | function handleClose() { |
| 135 | if (isPurchasing) return |
| 136 | setStep(1); |
| 137 | setSelectedEggId(""); |
| 138 | setSelectedDockerImage(""); |
| 139 | setEnvVarValues({}); |
| 140 | setServerName(""); |
| 141 | onClose(); |
| 142 | } |
| 143 | |
| 144 | function handleSelectEgg(egg |
| 145 | setSelectedEggId(egg.id |
| 146 | setSelectedDockerImage(egg.dockerImages |
| 147 | setShowAllImages(false); |
| 148 | const defaults: |
| 149 | for (const v |
| 150 | if (v.userEditable |
| 151 | } |
| 152 | setEnvVarValues(defaults); |
| 153 | } |
| 154 | |
| 155 | function handleDeploy() { |
| 156 | if (!selectedPriceId || |
| 157 | return; |
| 158 | onPurchase( |
| 159 | selectedPriceId, |
| 160 | selectedEggId, |
| 161 | serverName.trim(), |
| 162 | selectedDockerImage, |
| 163 | envVarValues, |
| 164 | ); |
| 165 | } |
| 166 | |
| 167 | const specs = |
| 168 | { icon: Cpu, |
| 169 | { icon: MemoryStick, |
| 170 | { icon: HardDrive, |
| 171 | { icon: Archive, |
| 172 | { |
| 173 | icon: Network, |
| 174 | value: resources.allocations |
| 175 | unit: t("specs.allocations |
| 176 | }, |
| 177 | { icon: Database, |
| 178 | ]; |
| 179 | |
| 180 | const steps = |
| 181 | d("stepDuration"), |
| 182 | d("stepInstaller"), |
| 183 | d("stepConfig"), |
| 184 | d("stepConfirm"), |
| 185 | ]; |
| 186 | |
| 187 | const breadcrumb = |
| 188 | <div className="flex items-center gap-1 flex-wrap |
| 189 | {steps.map(( |
| 190 | const n = ( |
| 191 | const done = |
| 192 | const active = |
| 193 | return ( |
| 194 | <div key={i |
| 195 | <div |
| 196 | className={cn( |
| 197 | "flex h-4 w-4 shrink-0 items-center justify-center rounded-full text-[9px] font-bold", |
| 198 | active && "bg-foreground text-background" |
| 199 | done && "bg-foreground/20 text-foreground" |
| 200 | !active && !done |
| 201 | )} |
| 202 | > |
| 203 | {done ? <Check |
| 204 | </div> |
| 205 | <span |
| 206 | className={cn( |
| 207 | "text-[11px] font-medium", |
| 208 | active ? "text-foreground" |
| 209 | )} |
| 210 | > |
| 211 | {label} |
| 212 | </span> |
| 213 | {i < steps |
| 214 | <ChevronRight className=" |
| 215 | )} |
| 216 | </div> |
| 217 | ); |
| 218 | })} |
| 219 | </div> |
| 220 | ); |
| 221 | |
| 222 | const stepContent = |
| 223 | <> |
| 224 | {step === 1 |
| 225 | <> |
| 226 | <div className="flex flex-col gap-2 |
| 227 | <p className="text-xs font-semibold text-foreground |
| 228 | {d("stepDuration") |
| 229 | </p> |
| 230 | <div className="flex flex-col gap-1.5 |
| 231 | {sortedPrices.map(( |
| 232 | const affordable = |
| 233 | const selected = |
| 234 | return ( |
| 235 | <button |
| 236 | key={p.id |
| 237 | type="button" |
| 238 | disabled={!affordable} |
| 239 | onClick={() => |
| 240 | className={cn( |
| 241 | "flex items-center justify-between rounded-xl border px-4 py-3 text-left transition-all", |
| 242 | selected |
| 243 | ? "border-foreground bg-foreground/5" |
| 244 | : "border-border hover:border-foreground/30", |
| 245 | !affordable && "cursor-not-allowed opacity-40 |
| 246 | )} |
| 247 | > |
| 248 | <div> |
| 249 | <p className="text-sm font-semibold |
| 250 | {t(`durations.${p |
| 251 | </p> |
| 252 | {!affordable && ( |
| 253 | <p className="text-[10px] text-destructive mt-0.5 |
| 254 | {t("topUpRequired") |
| 255 | </p> |
| 256 | )} |
| 257 | </div> |
| 258 | <div className="flex items-center gap-2 shrink-0 |
| 259 | <span className="text-sm font-bold tabular-nums |
| 260 | {formatPrice(p.price |
| 261 | </span> |
| 262 | <div |
| 263 | className={cn( |
| 264 | "flex h-4 w-4 items-center justify-center rounded-full border transition-colors", |
| 265 | selected |
| 266 | ? "border-foreground bg-foreground" |
| 267 | : "border-border", |
| 268 | )} |
| 269 | > |
| 270 | {selected && ( |
| 271 | <div className="h-1.5 w-1.5 rounded-full bg-background |
| 272 | )} |
| 273 | </div> |
| 274 | </div> |
| 275 | </button> |
| 276 | ); |
| 277 | })} |
| 278 | </div> |
| 279 | </div> |
| 280 | {!canAfford && selectedPrice |
| 281 | <p className="text-center text-xs |
| 282 | <Link |
| 283 | href={"/billing/wallet" |
| 284 | className="text-destructive underline" |
| 285 | onClick={handleClose} |
| 286 | > |
| 287 | {t("topUpRequired") |
| 288 | </Link> |
| 289 | </p> |
| 290 | )} |
| 291 | <div className="flex justify-end pt-1 |
| 292 | <Button |
| 293 | size="sm" |
| 294 | disabled={!selectedPriceId |
| 295 | onClick={() => |
| 296 | > |
| 297 | {d("next") |
| 298 | </Button> |
| 299 | </div> |
| 300 | </> |
| 301 | )} |
| 302 | |
| 303 | {step === 2 |
| 304 | <> |
| 305 | <div className="flex flex-col gap-2 |
| 306 | <p className="text-xs font-semibold text-foreground |
| 307 | {d("stepInstaller") |
| 308 | </p> |
| 309 | {eggsLoading ? ( |
| 310 | <div className="flex flex-col gap-1.5 |
| 311 | {[0, 1, 2 |
| 312 | <div |
| 313 | key={i} |
| 314 | className="h-12 animate-pulse rounded-xl border border-border bg-muted/30" |
| 315 | /> |
| 316 | ))} |
| 317 | </div> |
| 318 | ) : planEggs.length |
| 319 | <div className="py-6 text-center |
| 320 | <Server className=" |
| 321 | <p className="text-sm text-muted-foreground |
| 322 | {d("noInstallers") |
| 323 | </p> |
| 324 | </div> |
| 325 | ) : ( |
| 326 | <div className="flex flex-col gap-1.5 |
| 327 | {planEggs.map(( |
| 328 | const selected = |
| 329 | return ( |
| 330 | <button |
| 331 | key={egg.id |
| 332 | type="button" |
| 333 | onClick={() => |
| 334 | className={cn( |
| 335 | "flex items-center gap-3 rounded-xl border px-4 py-3 text-left transition-all", |
| 336 | selected |
| 337 | ? "border-foreground bg-foreground/5" |
| 338 | : "border-border hover:border-foreground/30", |
| 339 | )} |
| 340 | > |
| 341 | <div className="flex h-7 w-7 shrink-0 items-center justify-center rounded-lg bg-muted |
| 342 | <Server className=" |
| 343 | </div> |
| 344 | <span className="flex-1 text-sm font-medium |
| 345 | {egg.name} |
| 346 | </span> |
| 347 | <div |
| 348 | className={cn( |
| 349 | "flex h-4 w-4 items-center justify-center rounded-full border transition-colors", |
| 350 | selected |
| 351 | ? "border-foreground bg-foreground" |
| 352 | : "border-border", |
| 353 | )} |
| 354 | > |
| 355 | {selected && ( |
| 356 | <div className="h-1.5 w-1.5 rounded-full bg-background |
| 357 | )} |
| 358 | </div> |
| 359 | </button> |
| 360 | ); |
| 361 | })} |
| 362 | </div> |
| 363 | )} |
| 364 | </div> |
| 365 | <div className="flex justify-between pt-1 |
| 366 | <Button size=" |
| 367 | <ChevronLeft className=" |
| 368 | </Button> |
| 369 | <Button |
| 370 | size="sm" |
| 371 | disabled={!selectedEggId} |
| 372 | onClick={() => |
| 373 | > |
| 374 | {d("next") |
| 375 | </Button> |
| 376 | </div> |
| 377 | </> |
| 378 | )} |
| 379 | |
| 380 | {step === 3 |
| 381 | <> |
| 382 | {selectedEgg && selectedEgg |
| 383 | <div className="flex flex-col gap-2 |
| 384 | <p className="text-xs font-semibold text-foreground |
| 385 | {d("dockerImage") |
| 386 | </p> |
| 387 | <div className="flex flex-col gap-1.5 |
| 388 | {(showAllImages |
| 389 | ? selectedEgg.dockerImages |
| 390 | : selectedEgg.dockerImages. |
| 391 | ).map((img |
| 392 | const selected = |
| 393 | return ( |
| 394 | <button |
| 395 | key={img.image |
| 396 | type="button" |
| 397 | onClick={() => |
| 398 | className={cn( |
| 399 | "flex items-center gap-3 rounded-xl border px-4 py-3 text-left transition-all", |
| 400 | selected |
| 401 | ? "border-foreground bg-foreground/5" |
| 402 | : "border-border hover:border-foreground/30", |
| 403 | )} |
| 404 | > |
| 405 | <div className="flex-1 min-w-0 |
| 406 | <p className="text-sm font-medium |
| 407 | <p className="text-[10px] text-muted-foreground truncate mt-0.5 |
| 408 | {img.image} |
| 409 | </p> |
| 410 | </div> |
| 411 | <div |
| 412 | className={cn( |
| 413 | "flex h-4 w-4 items-center justify-center rounded-full border transition-colors shrink-0", |
| 414 | selected |
| 415 | ? "border-foreground bg-foreground" |
| 416 | : "border-border", |
| 417 | )} |
| 418 | > |
| 419 | {selected && ( |
| 420 | <div className="h-1.5 w-1.5 rounded-full bg-background |
| 421 | )} |
| 422 | </div> |
| 423 | </button> |
| 424 | ); |
| 425 | })} |
| 426 | {selectedEgg.dockerImages.length |
| 427 | <button |
| 428 | type="button" |
| 429 | onClick={() => |
| 430 | className="text-xs text-muted-foreground hover:text-foreground transition-colors py-1 text-center" |
| 431 | > |
| 432 | {showAllImages |
| 433 | ? d("showLess" |
| 434 | : d("showMore" |
| 435 | count: selectedEgg.dockerImages |
| 436 | })} |
| 437 | </button> |
| 438 | )} |
| 439 | </div> |
| 440 | </div> |
| 441 | )} |
| 442 | |
| 443 | {selectedEgg && |
| 444 | selectedEgg.variables.filter |
| 445 | <div className="flex flex-col gap-2 |
| 446 | <p className="text-xs font-semibold text-foreground |
| 447 | {d("envVars") |
| 448 | </p> |
| 449 | <div className="flex flex-col gap-3 |
| 450 | {selectedEgg.variables |
| 451 | .filter((v) |
| 452 | .map((v) |
| 453 | <div key={v |
| 454 | <label className="text-xs font-medium text-foreground |
| 455 | {v.name} |
| 456 | </label> |
| 457 | {v.description && |
| 458 | <p className="text-[10px] text-muted-foreground |
| 459 | {v.description} |
| 460 | </p> |
| 461 | )} |
| 462 | <Input |
| 463 | value={ |
| 464 | envVarValues[v.envVariable |
| 465 | } |
| 466 | onChange={(e) |
| 467 | setEnvVarValues((prev) |
| 468 | ...prev, |
| 469 | [v.envVariable]: |
| 470 | })) |
| 471 | } |
| 472 | placeholder={v.defaultValue |
| 473 | /> |
| 474 | </div> |
| 475 | ))} |
| 476 | </div> |
| 477 | </div> |
| 478 | )} |
| 479 | |
| 480 | {selectedEgg && |
| 481 | selectedEgg.dockerImages.length |
| 482 | selectedEgg.variables.filter |
| 483 | 0 && ( |
| 484 | <div className="py-6 text-center |
| 485 | <p className="text-sm text-muted-foreground |
| 486 | </div> |
| 487 | )} |
| 488 | |
| 489 | <div className="flex justify-between pt-1 |
| 490 | <Button size=" |
| 491 | <ChevronLeft className=" |
| 492 | </Button> |
| 493 | <Button size=" |
| 494 | {d("next") |
| 495 | </Button> |
| 496 | </div> |
| 497 | </> |
| 498 | )} |
| 499 | |
| 500 | {step === 4 |
| 501 | <> |
| 502 | <div className="flex flex-col gap-2 |
| 503 | <p className="text-xs font-semibold text-foreground |
| 504 | {d("serverName") |
| 505 | </p> |
| 506 | <Input |
| 507 | placeholder={d(" |
| 508 | value={serverName} |
| 509 | onChange={(e) |
| 510 | onKeyDown={(e) |
| 511 | if (e.key |
| 512 | handleDeploy(); |
| 513 | }} |
| 514 | maxLength={255} |
| 515 | autoFocus |
| 516 | /> |
| 517 | </div> |
| 518 | |
| 519 | {isMobile && ( |
| 520 | <div className="rounded-xl border border-border/60 overflow-hidden text-xs |
| 521 | <div className="flex justify-between px-3 py-2 border-b border-border/60 |
| 522 | <span className="text-muted-foreground |
| 523 | {d("summaryDuration") |
| 524 | </span> |
| 525 | <span className="font-medium |
| 526 | {selectedPrice |
| 527 | ? t(`durations.${ |
| 528 | : "-"} |
| 529 | </span> |
| 530 | </div> |
| 531 | {selectedEgg && ( |
| 532 | <div className="flex justify-between px-3 py-2 border-b border-border/60 |
| 533 | <span className="text-muted-foreground |
| 534 | {d("summaryInstaller") |
| 535 | </span> |
| 536 | <span className="font-medium |
| 537 | </div> |
| 538 | )} |
| 539 | {selectedEgg && |
| 540 | selectedEgg.dockerImages.length |
| 541 | selectedDockerImage && ( |
| 542 | <div className="flex justify-between px-3 py-2 border-b border-border/60 |
| 543 | <span className="text-muted-foreground |
| 544 | {d("summaryImage") |
| 545 | </span> |
| 546 | <span className="font-medium |
| 547 | {selectedEgg.dockerImages.find |
| 548 | (i) => |
| 549 | )?.name ?? selectedDockerImage |
| 550 | </span> |
| 551 | </div> |
| 552 | )} |
| 553 | <div className="flex justify-between px-3 py-2.5 bg-muted/30 |
| 554 | <span className="font-medium text-foreground |
| 555 | {d("summaryTotal") |
| 556 | </span> |
| 557 | <span className="font-bold tabular-nums text-foreground |
| 558 | {selectedPrice |
| 559 | ? formatPrice(selectedPrice. |
| 560 | : "-"} |
| 561 | </span> |
| 562 | </div> |
| 563 | </div> |
| 564 | )} |
| 565 | |
| 566 | {!canAfford && selectedPrice |
| 567 | <p className="text-center text-xs |
| 568 | <Link |
| 569 | href={"/billing/wallet" |
| 570 | className="text-destructive underline" |
| 571 | onClick={handleClose} |
| 572 | > |
| 573 | {t("topUpRequired") |
| 574 | </Link> |
| 575 | </p> |
| 576 | )} |
| 577 | |
| 578 | <div className="flex justify-between pt-1 |
| 579 | <Button |
| 580 | size="sm" |
| 581 | variant="ghost" |
| 582 | disabled={isPurchasing} |
| 583 | onClick={() => |
| 584 | > |
| 585 | <ChevronLeft className=" |
| 586 | </Button> |
| 587 | <Button |
| 588 | size="sm" |
| 589 | disabled={isPurchasing || |
| 590 | onClick={handleDeploy} |
| 591 | > |
| 592 | {isPurchasing ? d |
| 593 | </Button> |
| 594 | </div> |
| 595 | </> |
| 596 | )} |
| 597 | </> |
| 598 | ); |
| 599 | |
| 600 | const desktopContent = |
| 601 | <div className="grid grid-cols-[280px_1fr] h-[85vh] overflow-hidden |
| 602 | <div className="flex flex-col gap-5 p-6 bg-muted/30 border-r border-border overflow-y-auto min-h-0 |
| 603 | <div className="flex items-start gap-3 |
| 604 | <div className="flex h-10 w-10 shrink-0 items-center justify-center overflow-hidden rounded-xl border border-border bg-muted |
| 605 | {planIconUrl ? ( |
| 606 | <img |
| 607 | src={planIconUrl} |
| 608 | alt="" |
| 609 | className="h-full w-full object-cover" |
| 610 | /> |
| 611 | ) : ( |
| 612 | <Tag className=" |
| 613 | )} |
| 614 | </div> |
| 615 | <div className="min-w-0 |
| 616 | <p className="text-sm font-semibold text-foreground |
| 617 | {planDescription && ( |
| 618 | <p className="mt-0.5 text-xs leading-relaxed text-muted-foreground line-clamp-3 |
| 619 | {planDescription} |
| 620 | </p> |
| 621 | )} |
| 622 | </div> |
| 623 | </div> |
| 624 | |
| 625 | <div className="grid grid-cols-2 gap-x-4 gap-y-2 |
| 626 | {specs.map(( |
| 627 | <div key={i |
| 628 | <s.icon className=" |
| 629 | <span className="font-semibold tabular-nums text-foreground |
| 630 | {s.value} |
| 631 | </span> |
| 632 | <span className="text-muted-foreground |
| 633 | </div> |
| 634 | ))} |
| 635 | </div> |
| 636 | |
| 637 | <div className="flex flex-col gap-2 border-t border-border/60 pt-4 |
| 638 | <div className="flex justify-between text-xs |
| 639 | <span className="text-muted-foreground |
| 640 | {d("summaryDuration") |
| 641 | </span> |
| 642 | <span className="font-medium text-foreground |
| 643 | {selectedPrice ? t |
| 644 | </span> |
| 645 | </div> |
| 646 | {selectedEgg && ( |
| 647 | <div className="flex justify-between text-xs |
| 648 | <span className="text-muted-foreground |
| 649 | {d("summaryInstaller") |
| 650 | </span> |
| 651 | <span className="font-medium text-foreground |
| 652 | {selectedEgg.name} |
| 653 | </span> |
| 654 | </div> |
| 655 | )} |
| 656 | {selectedEgg && |
| 657 | selectedEgg.dockerImages.length |
| 658 | selectedDockerImage && ( |
| 659 | <div className="flex justify-between text-xs |
| 660 | <span className="text-muted-foreground |
| 661 | {d("summaryImage") |
| 662 | </span> |
| 663 | <span className="font-medium text-foreground |
| 664 | {selectedEgg.dockerImages.find |
| 665 | (i) => |
| 666 | )?.name ?? selectedDockerImage |
| 667 | </span> |
| 668 | </div> |
| 669 | )} |
| 670 | <div className="flex items-baseline justify-between border-t border-border/60 pt-2 mt-0.5 |
| 671 | <span className="text-xs text-muted-foreground |
| 672 | {d("summaryTotal") |
| 673 | </span> |
| 674 | <span className="text-xl font-bold tabular-nums text-foreground |
| 675 | {selectedPrice ? formatPrice |
| 676 | </span> |
| 677 | </div> |
| 678 | <p className="text-xs text-muted-foreground |
| 679 | {t("walletBalance", |
| 680 | balance: formatPrice(walletBalanceCents |
| 681 | })} |
| 682 | </p> |
| 683 | </div> |
| 684 | </div> |
| 685 | |
| 686 | <div className="flex flex-col gap-5 p-6 overflow-y-auto min-h-0 |
| 687 | <p className="text-sm font-semibold text-foreground pr-8 |
| 688 | {d("title") |
| 689 | </p> |
| 690 | {breadcrumb} |
| 691 | {stepContent} |
| 692 | </div> |
| 693 | </div> |
| 694 | ); |
| 695 | |
| 696 | const mobileContent = |
| 697 | <> |
| 698 | <div className="flex items-center gap-3 px-5 pt-4 pb-3 border-b border-border shrink-0 |
| 699 | <div className="flex h-8 w-8 shrink-0 items-center justify-center overflow-hidden rounded-lg border border-border bg-muted |
| 700 | {planIconUrl ? ( |
| 701 | <img |
| 702 | src={planIconUrl} |
| 703 | alt="" |
| 704 | className="h-full w-full object-cover" |
| 705 | /> |
| 706 | ) : ( |
| 707 | <Tag className=" |
| 708 | )} |
| 709 | </div> |
| 710 | <span className="flex-1 text-sm font-semibold text-foreground truncate |
| 711 | {planName} |
| 712 | </span> |
| 713 | {selectedPrice && ( |
| 714 | <span className="shrink-0 text-sm font-bold tabular-nums pr-8 |
| 715 | {formatPrice(selectedPrice.price |
| 716 | </span> |
| 717 | )} |
| 718 | </div> |
| 719 | <div className="flex flex-col gap-5 overflow-y-auto px-5 py-5 |
| 720 | {breadcrumb} |
| 721 | {stepContent} |
| 722 | </div> |
| 723 | </> |
| 724 | ); |
| 725 | |
| 726 | return isMobile ? |
| 727 | <Sheet |
| 728 | open={open} |
| 729 | onOpenChange={(v) |
| 730 | if (!v) handleClose |
| 731 | }} |
| 732 | > |
| 733 | <SheetPopup |
| 734 | side="bottom" |
| 735 | showCloseButton={!isPurchasing} |
| 736 | className="max-h-[92dvh]" |
| 737 | > |
| 738 | {mobileContent} |
| 739 | </SheetPopup> |
| 740 | </Sheet> |
| 741 | ) : ( |
| 742 | <Dialog |
| 743 | open={open} |
| 744 | onOpenChange={(v) |
| 745 | if (!v) handleClose |
| 746 | }} |
| 747 | > |
| 748 | <DialogPopup |
| 749 | className="w-full max-w-3xl overflow-hidden" |
| 750 | showCloseButton={!isPurchasing} |
| 751 | > |
| 752 | {desktopContent} |
| 753 | </DialogPopup> |
| 754 | </Dialog> |
| 755 | ); |
| 756 | } |
| 757 | |
| 758 | function PlanCard({ |
| 759 | planId, |
| 760 | name, |
| 761 | description, |
| 762 | iconUrl, |
| 763 | categoryIconUrl, |
| 764 | categoryName, |
| 765 | resources, |
| 766 | prices, |
| 767 | isFeatured, |
| 768 | currency, |
| 769 | soldOut, |
| 770 | walletBalanceCents, |
| 771 | walletLoaded, |
| 772 | onPurchase, |
| 773 | isPurchasing, |
| 774 | }: { |
| 775 | planId: string |
| 776 | soldOut: boolean |
| 777 | name: string |
| 778 | description: string |
| 779 | iconUrl: string |
| 780 | categoryIconUrl: string |
| 781 | categoryName: string |
| 782 | resources: ResourceLimits |
| 783 | prices: PlanPrice |
| 784 | isFeatured: boolean |
| 785 | currency: string |
| 786 | walletBalanceCents: number |
| 787 | walletLoaded: boolean |
| 788 | onPurchase: ( |
| 789 | priceId: string |
| 790 | eggId: string |
| 791 | serverName: string |
| 792 | dockerImage: string |
| 793 | envVars: Record |
| 794 | ) => |
| 795 | isPurchasing: boolean |
| 796 | }) { |
| 797 | const t = |
| 798 | const displayIcon = |
| 799 | const [dialogOpen, |
| 800 | |
| 801 | const sortedPrices = |
| 802 | (a, b) |
| 803 | DURATION_ORDER.indexOf(a |
| 804 | ); |
| 805 | const lowestPrice = |
| 806 | |
| 807 | const specs = |
| 808 | { icon: Cpu, |
| 809 | { icon: MemoryStick, |
| 810 | { icon: HardDrive, |
| 811 | { icon: Archive, |
| 812 | { |
| 813 | icon: Network, |
| 814 | value: resources.allocations |
| 815 | unit: t("specs.allocations |
| 816 | }, |
| 817 | { icon: Database, |
| 818 | ]; |
| 819 | |
| 820 | return ( |
| 821 | <> |
| 822 | <div |
| 823 | className={cn( |
| 824 | "relative flex flex-col overflow-hidden rounded-2xl border bg-card transition-colors hover:border-foreground/30", |
| 825 | isFeatured |
| 826 | ? "border-primary/40 ring-1 ring-primary/20" |
| 827 | : "border-border", |
| 828 | )} |
| 829 | > |
| 830 | {isFeatured && ( |
| 831 | <div className="absolute top-3 right-3 z-10 flex items-center gap-1 rounded-full bg-amber-500/15 px-2 py-0.5 text-[10px] font-semibold text-amber-500 |
| 832 | <Star className=" |
| 833 | {t("featured") |
| 834 | </div> |
| 835 | )} |
| 836 | |
| 837 | <div className="flex items-center gap-3 px-4 pt-4 pb-3 |
| 838 | <div className="flex h-10 w-10 shrink-0 items-center justify-center overflow-hidden rounded-xl border border-border bg-muted |
| 839 | {displayIcon ? ( |
| 840 | <img |
| 841 | src={displayIcon} |
| 842 | alt="" |
| 843 | className="h-full w-full object-cover" |
| 844 | /> |
| 845 | ) : ( |
| 846 | <Tag className=" |
| 847 | )} |
| 848 | </div> |
| 849 | <div className="min-w-0 flex-1 pr-16 |
| 850 | <p className="truncate text-[10px] font-medium uppercase tracking-wide text-muted-foreground/60 |
| 851 | {categoryName} |
| 852 | </p> |
| 853 | <p className="truncate text-sm font-semibold text-foreground |
| 854 | {name} |
| 855 | </p> |
| 856 | </div> |
| 857 | </div> |
| 858 | |
| 859 | {description && ( |
| 860 | <p className="px-4 pb-3 text-[11px] leading-relaxed text-muted-foreground line-clamp-2 |
| 861 | {description} |
| 862 | </p> |
| 863 | )} |
| 864 | |
| 865 | <div className="border-t border-border/60 bg-muted/20 px-4 py-3 grid grid-cols-2 gap-x-6 gap-y-2.5 |
| 866 | {specs.map(( |
| 867 | <div key={i |
| 868 | <s.icon className=" |
| 869 | <span className="font-semibold tabular-nums text-foreground |
| 870 | {s.value} |
| 871 | </span> |
| 872 | <span className="text-muted-foreground |
| 873 | </div> |
| 874 | ))} |
| 875 | </div> |
| 876 | |
| 877 | {lowestPrice && ( |
| 878 | <div className="px-4 py-3 |
| 879 | <p className="text-[10px] text-muted-foreground |
| 880 | {t("startingFrom") |
| 881 | </p> |
| 882 | <p className="text-2xl font-bold tabular-nums text-foreground |
| 883 | {formatPrice(lowestPrice.price |
| 884 | <span className="ml-1 text-xs font-normal text-muted-foreground |
| 885 | / {t(`durations.${lowestPrice |
| 886 | </span> |
| 887 | </p> |
| 888 | </div> |
| 889 | )} |
| 890 | |
| 891 | <div className="mt-auto border-t border-border/60 px-4 py-3 |
| 892 | {soldOut ? ( |
| 893 | <Button size=" |
| 894 | {t("soldOut") |
| 895 | </Button> |
| 896 | ) : !walletLoaded |
| 897 | <Button size=" |
| 898 | {t("getStarted") |
| 899 | </Button> |
| 900 | ) : ( |
| 901 | <Button |
| 902 | size="sm" |
| 903 | className="w-full" |
| 904 | disabled={isPurchasing} |
| 905 | onClick={() => |
| 906 | > |
| 907 | {t("selectPlan") |
| 908 | </Button> |
| 909 | )} |
| 910 | </div> |
| 911 | </div> |
| 912 | |
| 913 | <PurchaseDialog |
| 914 | open={dialogOpen} |
| 915 | onClose={() => |
| 916 | planId={planId} |
| 917 | planName={name} |
| 918 | planDescription={description} |
| 919 | planIconUrl={displayIcon} |
| 920 | resources={resources} |
| 921 | prices={prices} |
| 922 | currency={currency} |
| 923 | walletBalanceCents={walletBalanceCents} |
| 924 | onPurchase={(priceId, |
| 925 | onPurchase(priceId, eggId |
| 926 | }} |
| 927 | isPurchasing={isPurchasing} |
| 928 | /> |
| 929 | </> |
| 930 | ); |
| 931 | } |
| 932 | |
| 933 | export default function BillingCategoryPage |
| 934 | params, |
| 935 | }: { |
| 936 | params: Promise |
| 937 | }) { |
| 938 | const { slug |
| 939 | const t = |
| 940 | const queryClient = |
| 941 | const router = |
| 942 | |
| 943 | const { data: |
| 944 | orpc.billing.listCategories |
| 945 | ); |
| 946 | |
| 947 | const category = |
| 948 | |
| 949 | const { data: |
| 950 | ...orpc.billing. |
| 951 | enabled: !!category?.id |
| 952 | }); |
| 953 | |
| 954 | const { data: |
| 955 | orpc.billing.getConfig |
| 956 | ); |
| 957 | const { data: |
| 958 | orpc.billing.getWallet |
| 959 | ); |
| 960 | const walletBalanceCents = |
| 961 | const displayCurrency = |
| 962 | |
| 963 | const purchaseMutation = |
| 964 | ...orpc.billing. |
| 965 | meta: { customError: true |
| 966 | }); |
| 967 | |
| 968 | function handlePurchase( |
| 969 | priceId: string, |
| 970 | eggId: string, |
| 971 | serverName: string, |
| 972 | dockerImage: string, |
| 973 | envVars: Record |
| 974 | ) { |
| 975 | purchaseMutation.mutate( |
| 976 | { priceId, eggId |
| 977 | { |
| 978 | onSuccess: (data) |
| 979 | toast.success(t |
| 980 | void queryClient.invalidateQueries( |
| 981 | queryKey: orpc.billing |
| 982 | }); |
| 983 | void queryClient.invalidateQueries( |
| 984 | queryKey: orpc.billing |
| 985 | }); |
| 986 | router.push(`/servers/ |
| 987 | }, |
| 988 | onError: (err: |
| 989 | const code = |
| 990 | if (code === |
| 991 | toast.error(t |
| 992 | else if (code |
| 993 | toast.error(t |
| 994 | else if (code |
| 995 | toast.error(t |
| 996 | else if (code |
| 997 | toast.error(t |
| 998 | else toast.error( |
| 999 | }, |
| 1000 | }, |
| 1001 | ); |
| 1002 | } |
| 1003 | |
| 1004 | const isLoading = |
| 1005 | |
| 1006 | return ( |
| 1007 | <div className="flex-1 overflow-auto px-4 py-5 sm:px-6 |
| 1008 | <div className="mx-auto max-w-5xl flex flex-col gap-5 |
| 1009 | <Link |
| 1010 | href={"/billing" |
| 1011 | className="flex items-center gap-1.5 text-xs text-muted-foreground transition-colors hover:text-foreground w-fit" |
| 1012 | > |
| 1013 | <ChevronLeft className=" |
| 1014 | {t("backToCategories") |
| 1015 | </Link> |
| 1016 | |
| 1017 | {catsLoading ? ( |
| 1018 | <div className="h-14 animate-pulse rounded-2xl border border-border bg-muted/30 |
| 1019 | ) : category ? |
| 1020 | <div className="overflow-hidden rounded-2xl border border-border bg-card |
| 1021 | {category.bannerUrl && |
| 1022 | <div className="relative h-20 overflow-hidden |
| 1023 | <img |
| 1024 | src={category.bannerUrl |
| 1025 | alt="" |
| 1026 | className="h-full w-full object-cover" |
| 1027 | /> |
| 1028 | <div className="absolute inset-0 bg-gradient-to-r from-background/80 to-background/20 |
| 1029 | </div> |
| 1030 | )} |
| 1031 | <div |
| 1032 | className={cn( |
| 1033 | "flex items-center gap-3 px-4 py-3", |
| 1034 | category.bannerUrl && |
| 1035 | )} |
| 1036 | > |
| 1037 | <div className="flex h-9 w-9 shrink-0 items-center justify-center overflow-hidden rounded-lg border border-border bg-muted |
| 1038 | {category.icon ? |
| 1039 | <img |
| 1040 | src={category.icon |
| 1041 | alt="" |
| 1042 | className="h-full w-full object-cover" |
| 1043 | /> |
| 1044 | ) : ( |
| 1045 | <Layers className=" |
| 1046 | )} |
| 1047 | </div> |
| 1048 | <div className="min-w-0 flex-1 |
| 1049 | <h1 className="truncate text-sm font-semibold text-foreground |
| 1050 | {category.name} |
| 1051 | </h1> |
| 1052 | {category.description && |
| 1053 | <p className="truncate text-xs text-muted-foreground |
| 1054 | {category.description} |
| 1055 | </p> |
| 1056 | )} |
| 1057 | </div> |
| 1058 | {walletLoaded && ( |
| 1059 | <p className="shrink-0 text-xs text-muted-foreground |
| 1060 | {t("walletBalance", |
| 1061 | balance: formatPrice( |
| 1062 | walletBalanceCents / 100, |
| 1063 | displayCurrency, |
| 1064 | ), |
| 1065 | })} |
| 1066 | </p> |
| 1067 | )} |
| 1068 | </div> |
| 1069 | </div> |
| 1070 | ) : null} |
| 1071 | |
| 1072 | {isLoading ? ( |
| 1073 | <div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 |
| 1074 | {Array.from({ |
| 1075 | <div |
| 1076 | key={i} |
| 1077 | className="h-72 animate-pulse rounded-2xl border border-border bg-muted/30" |
| 1078 | /> |
| 1079 | ))} |
| 1080 | </div> |
| 1081 | ) : products.length |
| 1082 | <div className="flex flex-col items-center gap-3 py-20 text-center |
| 1083 | <Tag className=" |
| 1084 | <p className="text-sm text-muted-foreground |
| 1085 | </div> |
| 1086 | ) : ( |
| 1087 | <div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 |
| 1088 | {products.map(( |
| 1089 | <PlanCard |
| 1090 | key={product.id |
| 1091 | planId={product.planId |
| 1092 | name={product.name |
| 1093 | description={product.description |
| 1094 | iconUrl={product.icon |
| 1095 | categoryIconUrl={category?.icon |
| 1096 | categoryName={category?.name |
| 1097 | resources={product.resources |
| 1098 | prices={product.prices |
| 1099 | isFeatured={product.isFeatured |
| 1100 | currency={displayCurrency} |
| 1101 | soldOut={product.soldOut |
| 1102 | walletBalanceCents={walletBalanceCents} |
| 1103 | walletLoaded={walletLoaded} |
| 1104 | onPurchase={handlePurchase} |
| 1105 | isPurchasing={purchaseMutation.isPending |
| 1106 | /> |
| 1107 | ))} |
| 1108 | </div> |
| 1109 | )} |
| 1110 | </div> |
| 1111 | </div> |
| 1112 | ); |
| 1113 | } |