@lukoweb/apitogo-module-landing 0.1.58 → 0.1.59

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lukoweb/apitogo-module-landing",
3
- "version": "0.1.58",
3
+ "version": "0.1.59",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  ".": "./src/index.tsx"
@@ -14,7 +14,7 @@
14
14
  "devDependencies": {
15
15
  "@types/react": "19.2.14",
16
16
  "@types/react-dom": "19.2.3",
17
- "@lukoweb/apitogo": "0.1.58",
17
+ "@lukoweb/apitogo": "0.1.59",
18
18
  "react": "19.2.4",
19
19
  "react-dom": "19.2.4",
20
20
  "typescript": "5.9.3"
@@ -45,6 +45,7 @@ export const DefaultLandingPage = ({ content }: DefaultLandingPageProps) => {
45
45
  {content.pricingCta ? (
46
46
  <PricingCtaSection pricingCta={content.pricingCta} />
47
47
  ) : null}
48
+ {/* content.footer renders portal-wide via the layout "footer" slot. */}
48
49
  </LandingShell>
49
50
  );
50
51
  };
@@ -0,0 +1,26 @@
1
+ import type { ResolvedLandingContent } from "@lukoweb/apitogo";
2
+ import type { ComponentType } from "react";
3
+ import { FooterSection } from "./sections/FooterSection.js";
4
+ import "./landing.css";
5
+
6
+ export type LandingFooterSlotConfig = ResolvedLandingContent["footer"];
7
+
8
+ /**
9
+ * Builds a component for the layout "footer" slot so the landing footer
10
+ * renders on every page (home, docs, API reference, pricing, auth).
11
+ */
12
+ export const createLandingFooterSlot = (
13
+ footer: LandingFooterSlotConfig,
14
+ ): ComponentType | undefined => {
15
+ if (!footer || footer.enabled === false) {
16
+ return undefined;
17
+ }
18
+
19
+ const LandingFooterSlot = () => (
20
+ <div className="apitogo-landing">
21
+ <FooterSection footer={footer} />
22
+ </div>
23
+ );
24
+
25
+ return LandingFooterSlot;
26
+ };
@@ -1,15 +1,37 @@
1
1
  import { cn } from "@lukoweb/apitogo";
2
2
  import { Head, useZudoku } from "@lukoweb/apitogo/components";
3
- import type { ReactNode } from "react";
3
+ import { Children, isValidElement, type ReactNode } from "react";
4
+ import { FooterSection } from "./sections/FooterSection.js";
4
5
  import "./landing.css";
5
6
 
7
+ /**
8
+ * Detects a composed `FooterSection` in the element tree so the
9
+ * "Built with APIToGo" line can be replaced by the real footer.
10
+ */
11
+ const containsFooterSection = (node: ReactNode): boolean =>
12
+ Children.toArray(node).some((child) => {
13
+ if (!isValidElement(child)) {
14
+ return false;
15
+ }
16
+
17
+ if (child.type === FooterSection) {
18
+ return true;
19
+ }
20
+
21
+ const { children } = child.props as { children?: ReactNode };
22
+ return children ? containsFooterSection(children) : false;
23
+ });
24
+
6
25
  export type LandingShellProps = {
7
26
  children: ReactNode;
8
27
  /** Page title for `<Head>`. Defaults to site title from config. */
9
28
  title?: string;
10
29
  /** Meta description for `<Head>`. */
11
30
  description?: string;
12
- /** Show "Built with APIToGo" footer. Defaults to true. */
31
+ /**
32
+ * Show the "Built with APIToGo" line. Defaults to true.
33
+ * Automatically hidden when a `FooterSection` is composed as a child.
34
+ */
13
35
  showPoweredBy?: boolean;
14
36
  };
15
37
 
@@ -23,6 +45,7 @@ export const LandingShell = ({
23
45
  const siteTitle =
24
46
  options.metadata?.applicationName ?? options.site?.title ?? "APIToGo";
25
47
  const pageTitle = title ?? siteTitle;
48
+ const hasFooter = containsFooterSection(children);
26
49
 
27
50
  return (
28
51
  <div className="apitogo-landing relative overflow-hidden">
@@ -43,7 +66,7 @@ export const LandingShell = ({
43
66
 
44
67
  {children}
45
68
 
46
- {showPoweredBy ? (
69
+ {showPoweredBy && !hasFooter ? (
47
70
  <div
48
71
  className={cn(
49
72
  "landing-powered-by py-6 text-center text-xs",
package/src/index.tsx CHANGED
@@ -4,6 +4,8 @@ export { LandingShell } from "./LandingShell.js";
4
4
  export type { LandingShellProps } from "./LandingShell.js";
5
5
  export { landingModule } from "./landingModule.js";
6
6
  export type { LandingModuleOptions } from "./landingModule.js";
7
+ export { createLandingFooterSlot } from "./LandingFooterSlot.js";
8
+ export type { LandingFooterSlotConfig } from "./LandingFooterSlot.js";
7
9
  export { HeroSection } from "./sections/HeroSection.js";
8
10
  export type { HeroSectionProps } from "./sections/HeroSection.js";
9
11
  export { TrustSection } from "./sections/TrustSection.js";
@@ -14,5 +16,7 @@ export { ApiShowcaseSection } from "./sections/ApiShowcaseSection.js";
14
16
  export type { ApiShowcaseSectionProps } from "./sections/ApiShowcaseSection.js";
15
17
  export { PricingCtaSection } from "./sections/PricingCtaSection.js";
16
18
  export type { PricingCtaSectionProps } from "./sections/PricingCtaSection.js";
19
+ export { FooterSection } from "./sections/FooterSection.js";
20
+ export type { FooterSectionProps } from "./sections/FooterSection.js";
17
21
  export { CodeExampleCard } from "./sections/CodeExampleCard.js";
18
22
  export type { CodeExampleCardProps } from "./sections/CodeExampleCard.js";
package/src/landing.css CHANGED
@@ -263,3 +263,7 @@
263
263
  border-top: 1px solid var(--landing-border);
264
264
  color: var(--landing-text-3);
265
265
  }
266
+
267
+ .apitogo-landing .landing-footer-social-link {
268
+ color: var(--landing-text-3);
269
+ }
@@ -0,0 +1,152 @@
1
+ import type { ResolvedLandingContent } from "@lukoweb/apitogo";
2
+ import { useZudoku } from "@lukoweb/apitogo/components";
3
+ import type { ReactElement } from "react";
4
+
5
+ export type FooterSectionProps = {
6
+ footer: NonNullable<ResolvedLandingContent["footer"]>;
7
+ };
8
+
9
+ const GitHubIcon = ({ className }: { className?: string }) => (
10
+ <svg
11
+ viewBox="0 0 24 24"
12
+ fill="currentColor"
13
+ className={className}
14
+ aria-hidden
15
+ >
16
+ <path d="M12 0C5.37 0 0 5.37 0 12c0 5.31 3.435 9.795 8.205 11.385.6.105.825-.255.825-.57 0-.285-.015-1.23-.015-2.235-3.015.555-3.795-.735-4.035-1.41-.135-.345-.72-1.41-1.23-1.695-.42-.225-1.02-.78-.015-.795.945-.015 1.62.87 1.845 1.23 1.08 1.815 2.805 1.305 3.495.99.105-.78.42-1.305.765-1.605-2.67-.3-5.46-1.335-5.46-5.925 0-1.305.465-2.385 1.23-3.225-.12-.3-.54-1.53.12-3.18 0 0 1.005-.315 3.3 1.23.96-.27 1.98-.405 3-.405s2.04.135 3 .405c2.295-1.56 3.3-1.23 3.3-1.23.66 1.65.24 2.88.12 3.18.765.84 1.23 1.905 1.23 3.225 0 4.605-2.805 5.625-5.475 5.925.435.375.81 1.095.81 2.22 0 1.605-.015 2.895-.015 3.3 0 .315.225.69.825.57A12.02 12.02 0 0 0 24 12c0-6.63-5.37-12-12-12Z" />
17
+ </svg>
18
+ );
19
+
20
+ const XIcon = ({ className }: { className?: string }) => (
21
+ <svg
22
+ viewBox="0 0 24 24"
23
+ fill="currentColor"
24
+ className={className}
25
+ aria-hidden
26
+ >
27
+ <path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z" />
28
+ </svg>
29
+ );
30
+
31
+ const SOCIAL_ICONS: Record<
32
+ string,
33
+ (props: { className?: string }) => ReactElement
34
+ > = {
35
+ github: GitHubIcon,
36
+ x: XIcon,
37
+ twitter: XIcon,
38
+ };
39
+
40
+ const FooterSocialLink = ({
41
+ icon,
42
+ href,
43
+ label,
44
+ }: {
45
+ icon: string;
46
+ href: string;
47
+ label?: string;
48
+ }) => {
49
+ const Icon = SOCIAL_ICONS[icon.toLowerCase()];
50
+ const accessibleLabel = label ?? icon;
51
+
52
+ return (
53
+ <a
54
+ href={href}
55
+ target="_blank"
56
+ rel="noopener noreferrer"
57
+ aria-label={accessibleLabel}
58
+ className="landing-footer-social-link landing-muted-3 inline-flex items-center justify-center transition-colors hover:text-[var(--landing-text)]"
59
+ >
60
+ {Icon ? (
61
+ <Icon className="h-[1.125rem] w-[1.125rem]" />
62
+ ) : (
63
+ <span className="text-xs font-medium">{accessibleLabel}</span>
64
+ )}
65
+ </a>
66
+ );
67
+ };
68
+
69
+ export const FooterSection = ({ footer }: FooterSectionProps) => {
70
+ const { options } = useZudoku();
71
+ const siteTitle =
72
+ options.metadata?.applicationName ?? options.site?.title ?? "APIToGo";
73
+
74
+ const brandName = footer.brand?.name ?? siteTitle;
75
+ const tagline = footer.brand?.tagline;
76
+ const logoSrc = footer.brand?.logoSrc;
77
+ const columns = footer.columns ?? [];
78
+ const social = footer.social ?? [];
79
+ const year = new Date().getFullYear();
80
+ const copyright =
81
+ footer.copyright ?? `© ${year} ${brandName}. All rights reserved.`;
82
+
83
+ if (columns.length === 0 && !tagline && !logoSrc && social.length === 0) {
84
+ return null;
85
+ }
86
+
87
+ return (
88
+ <footer className="landing-footer border-t border-[var(--landing-border)] bg-[var(--landing-bg-2)]">
89
+ <div className="mx-auto max-w-[1200px] px-6 py-12">
90
+ <div className="grid gap-10 sm:grid-cols-2 lg:grid-cols-[1.4fr_repeat(3,minmax(0,1fr))]">
91
+ <div className="sm:col-span-2 lg:col-span-1">
92
+ <div className="mb-3 flex items-center gap-2.5">
93
+ {logoSrc ? (
94
+ <img
95
+ src={logoSrc}
96
+ alt={brandName}
97
+ className="h-7 w-auto object-contain"
98
+ />
99
+ ) : (
100
+ <span className="landing-display text-[1.125rem] font-bold tracking-[-0.02em]">
101
+ <span className="text-[var(--landing-peri)]">API</span>
102
+ <span>ToGo</span>
103
+ </span>
104
+ )}
105
+ </div>
106
+ {tagline ? (
107
+ <p className="landing-muted max-w-[280px] text-[0.875rem] leading-relaxed">
108
+ {tagline}
109
+ </p>
110
+ ) : null}
111
+ </div>
112
+
113
+ {columns.map((column) => (
114
+ <div key={column.title}>
115
+ <h3 className="mb-4 text-[0.875rem] font-semibold text-[var(--landing-text)]">
116
+ {column.title}
117
+ </h3>
118
+ <ul className="space-y-2.5">
119
+ {column.links.map((link) => (
120
+ <li key={`${column.title}-${link.label}`}>
121
+ <a
122
+ href={link.href}
123
+ className="landing-muted text-[0.875rem] transition-colors hover:text-[var(--landing-text)]"
124
+ >
125
+ {link.label}
126
+ </a>
127
+ </li>
128
+ ))}
129
+ </ul>
130
+ </div>
131
+ ))}
132
+ </div>
133
+
134
+ <div className="landing-footer-bar mt-10 flex flex-col items-center justify-between gap-4 border-t border-[var(--landing-border)] pt-6 sm:flex-row">
135
+ <p className="landing-muted-3 text-[0.8125rem]">{copyright}</p>
136
+ {social.length > 0 ? (
137
+ <div className="flex items-center gap-4">
138
+ {social.map((item) => (
139
+ <FooterSocialLink
140
+ key={`${item.icon}-${item.href}`}
141
+ icon={item.icon}
142
+ href={item.href}
143
+ label={item.label}
144
+ />
145
+ ))}
146
+ </div>
147
+ ) : null}
148
+ </div>
149
+ </div>
150
+ </footer>
151
+ );
152
+ };