diff --git a/apps/app/next.config.mjs b/apps/app/next.config.mjs index dfe3d68c9..26b7ab878 100644 --- a/apps/app/next.config.mjs +++ b/apps/app/next.config.mjs @@ -1,5 +1,6 @@ import analyzer from '@next/bundle-analyzer'; import { getPreviewApiUrl } from '@op/core/previews'; +import { withTranspiledWorkspacesForNext } from '@op/styles/tailwind-utils'; import { withPostHogConfig } from '@posthog/nextjs-config'; import dotenv from 'dotenv'; import createNextIntlPlugin from 'next-intl/plugin'; @@ -153,12 +154,15 @@ const currentBranch = getCurrentBranch(); const allowedBranches = ['dev', 'main']; const shouldUploadSourcemaps = allowedBranches.includes(currentBranch); -export default withPostHogConfig(withBundleAnalyzer(withNextIntl(config)), { - personalApiKey: process.env.POSTHOG_API_KEY, - envId: process.env.POSTHOG_ENV_ID, - project: 'common', - host: 'https://eu.i.posthog.com', - sourcemaps: { - enabled: shouldUploadSourcemaps, +export default withPostHogConfig( + withBundleAnalyzer(withNextIntl(withTranspiledWorkspacesForNext(config))), + { + personalApiKey: process.env.POSTHOG_API_KEY, + envId: process.env.POSTHOG_ENV_ID, + project: 'common', + host: 'https://eu.i.posthog.com', + sourcemaps: { + enabled: shouldUploadSourcemaps, + }, }, -}); +); diff --git a/apps/app/package.json b/apps/app/package.json index fec791ae5..4da39774f 100644 --- a/apps/app/package.json +++ b/apps/app/package.json @@ -25,7 +25,7 @@ "@op/hooks": "workspace:*", "@op/logging": "workspace:*", "@op/realtime": "workspace:*", - "@op/styles": "workspace:^", + "@op/styles": "workspace:*", "@op/supabase": "workspace:*", "@op/types": "workspace:*", "@op/ui": "workspace:*", diff --git a/apps/app/postcss.config.mjs b/apps/app/postcss.config.mjs index 8670f4e1b..16955e170 100644 --- a/apps/app/postcss.config.mjs +++ b/apps/app/postcss.config.mjs @@ -1,8 +1,3 @@ -// PostCSS config for Tailwind CSS v4 -// https://tailwindcss.com/docs/installation/using-postcss +import { postcssConfig } from '@op/styles/postcss'; -export default { - plugins: { - '@tailwindcss/postcss': {}, - }, -}; +export default postcssConfig; diff --git a/apps/app/public/coming-soon-hero.png b/apps/app/public/coming-soon-hero.png new file mode 100644 index 000000000..61f334d3f Binary files /dev/null and b/apps/app/public/coming-soon-hero.png differ diff --git a/apps/app/public/logo-center-for-economic-democracy.png b/apps/app/public/logo-center-for-economic-democracy.png new file mode 100644 index 000000000..0ade05ac5 Binary files /dev/null and b/apps/app/public/logo-center-for-economic-democracy.png differ diff --git a/apps/app/public/logo-common.svg b/apps/app/public/logo-common.svg new file mode 100644 index 000000000..eb7d3d5b6 --- /dev/null +++ b/apps/app/public/logo-common.svg @@ -0,0 +1,34 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/app/public/logo-maria-fund.png b/apps/app/public/logo-maria-fund.png new file mode 100644 index 000000000..69882dd4f Binary files /dev/null and b/apps/app/public/logo-maria-fund.png differ diff --git a/apps/app/public/logo-new-economy-coalition.png b/apps/app/public/logo-new-economy-coalition.png new file mode 100644 index 000000000..dec371d51 Binary files /dev/null and b/apps/app/public/logo-new-economy-coalition.png differ diff --git a/apps/app/public/logo-people-powered.png b/apps/app/public/logo-people-powered.png new file mode 100644 index 000000000..f3485fbc1 Binary files /dev/null and b/apps/app/public/logo-people-powered.png differ diff --git a/apps/app/src/app/api/waitlist-signup/route.ts b/apps/app/src/app/api/waitlist-signup/route.ts index 90db450a9..53595cb78 100644 --- a/apps/app/src/app/api/waitlist-signup/route.ts +++ b/apps/app/src/app/api/waitlist-signup/route.ts @@ -11,12 +11,20 @@ mailchimpClient.setConfig({ export async function POST(req: NextRequest) { const body = await req.json(); - const { email } = body; + const { firstName, lastName, email, organizationName } = body; + + console.log({ firstName, lastName, email, organizationName }); if (!email) { return Response.json({ error: 'Email is required' }, { status: 400 }); } + if (!firstName) { + return Response.json({ error: 'First name is required' }, { status: 400 }); + } + if (!lastName) { + return Response.json({ error: 'Last name is required' }, { status: 400 }); + } // Generate hash from email to check if exists in Mailchimp // If it exists, we'll update the user using `setListMember` // That way, this endpoint doesn't fail if a user signs up twice diff --git a/apps/app/src/components/screens/ComingSoon/ComingSoonScreen.tsx b/apps/app/src/components/screens/ComingSoon/ComingSoonScreen.tsx index 18df1b0fd..859d2419a 100644 --- a/apps/app/src/components/screens/ComingSoon/ComingSoonScreen.tsx +++ b/apps/app/src/components/screens/ComingSoon/ComingSoonScreen.tsx @@ -1,72 +1,109 @@ -import { SoftBlobs } from '@op/ui/ShaderBackground'; -import * as motion from 'motion/react-client'; +import { ButtonLink } from '@op/ui/Button'; +import { LogoLoop } from '@op/ui/LogoLoop'; +import { cn } from '@op/ui/utils'; import { WaitlistSignup } from './WaitlistSignup'; export const ComingSoonScreen = () => { - const backgroundTransition = { - duration: 2, - ease: 'linear', - delay: 1, - }; return ( -
-
- +
+
+
+ Common + - - - {/* Fade top and bottom edges on mobile */} - {/* uses -[black] because -black is renamed in our color system */} -
-
-
-
- {/* Render the sign in link first for screenreaders */} - - Already have an account?{' '} - - Sign in - - - - Common.{' '} - - Connecting people, organizations, and resources to coordinate and - grow economic democracy to global scale. - - -
- - - -
-
+ Log in + +
+
+
+

+ + Helping people decide together how to use their resources—{' '} + + + simply, intuitively, and effectively. + +

+
+ Screenshot of the Common platform +
+
+

+ Built for{' '} + communities ready + to share power and co-create{' '} + social change + —
and funders who + trust them to lead. +

+

No setup headaches. No learning curve.

+

+ Common just works, instantly, for{' '} + everyone. +

+
+
+
+

Trusted by

+ +
+
+

Get early access

+
+

We’re getting ready to welcome more organizations to Common.

+

Sign up now to hold your spot.

+
+ +
+
+
+

Beautifully designed

+

+

Easy to set up

+

+

No training required

+
); }; + +const FancyWord = ({ + className, + children, +}: { + className?: string; + children: React.ReactNode; +}) => ( + + {children} + +); + +const logos = [ + { src: '/logo-people-powered.png', alt: 'People Powered' }, + { src: '/logo-maria-fund.png', alt: 'MariaFund' }, + { src: '/logo-new-economy-coalition.png', alt: 'New Economy Coalition' }, + { + src: '/logo-center-for-economic-democracy.png', + alt: 'Center for Economic Democracy', + }, +]; diff --git a/apps/app/src/components/screens/ComingSoon/WaitlistSignup.tsx b/apps/app/src/components/screens/ComingSoon/WaitlistSignup.tsx index c955cab28..c4d78bf20 100644 --- a/apps/app/src/components/screens/ComingSoon/WaitlistSignup.tsx +++ b/apps/app/src/components/screens/ComingSoon/WaitlistSignup.tsx @@ -1,39 +1,83 @@ 'use client'; +import { Button } from '@op/ui/Button'; +import { IconButton } from '@op/ui/IconButton'; import { LoadingSpinner } from '@op/ui/LoadingSpinner'; import { toast } from '@op/ui/Toast'; import { useState } from 'react'; +import { + Dialog, + DialogTrigger, + Heading, + Modal, + ModalOverlay, +} from 'react-aria-components'; +import { LuX } from 'react-icons/lu'; +import { tv } from 'tailwind-variants'; import { z } from 'zod'; import { getFieldErrorMessage, useAppForm } from '@/components/form/utils'; const validator = z.object({ + firstName: z.string().min(1, 'Please enter your first name'), + lastName: z.string().min(1, 'Please enter your last name'), email: z.email({ error: 'Please enter a valid email address' }), + organizationName: z.string(), }); +const overlayStyles = tv({ + base: 'absolute top-0 left-0 isolate z-20 h-(--page-height) w-full bg-white/[50%] text-center backdrop-blur-[3px]', + variants: { + isEntering: { + true: 'animate-in duration-200 ease-out fade-in', + }, + isExiting: { + true: 'animate-out duration-200 ease-in fade-out', + }, + }, +}); + +const modalStyles = tv({ + base: 'max-h-[calc(var(--visual-viewport-height)*.9)] w-full max-w-[min(90vw,450px)] rounded-2xl border border-black/10 bg-white bg-clip-padding text-left align-middle font-sans text-neutral-700 shadow-2xl dark:border-white/10 dark:bg-neutral-800/70 dark:text-neutral-300 dark:backdrop-blur-2xl dark:backdrop-saturate-200 forced-colors:bg-[Canvas]', + variants: { + isEntering: { + true: 'animate-in duration-200 ease-out zoom-in-105', + }, + isExiting: { + true: 'animate-out duration-200 ease-in zoom-out-95', + }, + }, +}); export const WaitlistSignup = () => { const [isSubmitted, setIsSubmitted] = useState(false); - return ( -
-
-

- Join the waitlist -

-

We'll email you when we launch publicly.

-
{' '} - {isSubmitted ? ( - - ) : ( - setIsSubmitted(true)} /> - )} -
+ + + +
+ + + {isSubmitted ? ( + + ) : ( + setIsSubmitted(true)} /> + )} + + +
+
+
); }; const WaitlistSignupForm = ({ onSuccess }: { onSuccess: () => void }) => { const form = useAppForm({ - defaultValues: { email: '' }, + defaultValues: { + firstName: '', + lastName: '', + email: '', + organizationName: '', + }, validators: { onSubmitAsync: async ({ value, @@ -64,53 +108,123 @@ const WaitlistSignupForm = ({ onSuccess }: { onSuccess: () => void }) => { }); return ( -
{ - e.preventDefault(); - e.stopPropagation(); - void form.handleSubmit(); - }} - > - ( - - )} - /> + <> +
+ + Common + + + + +
+

+ Get early access. We’re getting ready to welcome more organizations to + Common. Sign up now to hold your spot. +

+ { + e.preventDefault(); + e.stopPropagation(); + void form.handleSubmit(); + }} + > + ( + + )} + /> + ( + + )} + /> + ( + + )} + /> + ( + + )} + /> - [formState.isSubmitting]}> - {([isSubmitting]) => ( - - {isSubmitting ? : 'Join waitlist'} - - )} - - + [formState.isSubmitting]}> + {([isSubmitting]) => ( + + {isSubmitting ? : 'Join the waitlist'} + + )} + + + ); }; const WaitlistSignupSuccess = () => (
-

- You're signed up! We'll be in touch. +

+ You're on the list! +

+

+ We can’t wait to see you on Common, as an early collaborator in creating + an economy that works for everyone.

+

We'll be in touch soon!

); diff --git a/package.json b/package.json index 06d768be0..79af42acb 100644 --- a/package.json +++ b/package.json @@ -34,6 +34,7 @@ }, "devDependencies": { "@iconify/json": "2.2.311", + "@tailwindcss/cli": "4.1.18", "@op/core": "workspace:*", "@op/typescript-config": "workspace:*", "@react-three/drei": "^10.0.4", diff --git a/packages/styles/package.json b/packages/styles/package.json index 956cbbffa..72b7dda7e 100644 --- a/packages/styles/package.json +++ b/packages/styles/package.json @@ -5,12 +5,22 @@ "license": "SEE LICENSE in /LICENSE", "type": "module", "exports": { - ".": "./shared-styles.css", + ".": "./dist/index.css", + "./source": "./shared-styles.css", "./tokens": "./tokens.css", "./postcss": "./postcss.config.mjs", - "./constants": "./constants.ts" + "./constants": "./constants.ts", + "./tailwind-utils": "./tailwind.utils.mjs" + }, + "scripts": { + "build": "pnpm build:styles", + "build:styles": "tailwindcss -i ./styles.css -o ./dist/index.css --minify", + "dev": "tailwindcss -i ./styles.css -o ./dist/index.css --watch", + "postinstall": "tailwindcss -i ./styles.css -o ./dist/index.css", + "typecheck": "tsgo --noEmit" }, "devDependencies": { + "@tailwindcss/cli": "4.1.18", "@tailwindcss/postcss": "4.1.18", "@tailwindcss/typography": "^0.5.19", "postcss": "^8.5.3", diff --git a/packages/styles/shared-styles.css b/packages/styles/shared-styles.css index 23748e58d..29f3066b3 100644 --- a/packages/styles/shared-styles.css +++ b/packages/styles/shared-styles.css @@ -3,9 +3,6 @@ @import './tw-animate.css'; @import './intent-ui-theme.css'; -/* Tell Tailwind where to scan for classes in the UI package */ -@source '../ui/src/**/*.{js,ts,jsx,tsx}'; - /* React Aria Components data attribute variants */ @plugin 'tailwindcss-react-aria-components'; @plugin '@tailwindcss/typography'; diff --git a/packages/styles/styles.css b/packages/styles/styles.css new file mode 100644 index 000000000..3b328ceab --- /dev/null +++ b/packages/styles/styles.css @@ -0,0 +1,7 @@ +@import './shared-styles.css'; + +/* Scan UI package for Tailwind classes */ +@source '../ui/src/**/*.{js,ts,jsx,tsx}'; + +/* Scan app for Tailwind classes */ +@source '../../apps/app/src/**/*.{js,ts,jsx,tsx}'; diff --git a/packages/styles/tailwind.utils.mjs b/packages/styles/tailwind.utils.mjs new file mode 100644 index 000000000..cfe722632 --- /dev/null +++ b/packages/styles/tailwind.utils.mjs @@ -0,0 +1,30 @@ +const packagesUsingTailwind = { + '@op/ui': '../../packages/ui/src/**/*.{js,ts,jsx,tsx}', +}; + +export const withUITailwindPreset = ( + /** @type {Pick} */ + config, +) => { + return { + ...config, + content: [...config.content, ...Object.values(packagesUsingTailwind)], + }; +}; + +export const withTranspiledWorkspacesForNext = ( + /** @type {import('next').NextConfig} */ + config, +) => { + return { + ...config, + transpilePackages: [ + ...(config.transpilePackages || []), + ...Object.keys(packagesUsingTailwind), + ], + experimental: { + ...(config.experimental || {}), + optimizePackageImports: Object.keys(packagesUsingTailwind), + }, + }; +}; diff --git a/packages/styles/turbo.json b/packages/styles/turbo.json new file mode 100644 index 000000000..3096478d2 --- /dev/null +++ b/packages/styles/turbo.json @@ -0,0 +1,12 @@ +{ + "extends": ["//"], + "tasks": { + "build": { + "outputs": ["dist/**"] + }, + "dev": { + "cache": false, + "persistent": true + } + } +} diff --git a/packages/ui/package.json b/packages/ui/package.json index 4a783c591..60da11616 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -26,6 +26,7 @@ "./Header": "./src/components/Header.tsx", "./HorizontalList": "./src/components/HorizontalList/index.tsx", "./IconButton": "./src/components/IconButton.tsx", + "./LogoLoop": "./src/components/LogoLoop.tsx", "./Menu": "./src/components/Menu.tsx", "./Modal": "./src/components/Modal.tsx", "./MultiSelectComboBox": "./src/components/MultiSelectComboBox.tsx", diff --git a/packages/ui/src/components/LogoLoop.tsx b/packages/ui/src/components/LogoLoop.tsx new file mode 100644 index 000000000..5917c9826 --- /dev/null +++ b/packages/ui/src/components/LogoLoop.tsx @@ -0,0 +1,551 @@ +// From https://reactbits.dev/animations/logo-loop +import React, { + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from 'react'; + +export type LogoItem = + | { + node: React.ReactNode; + href?: string; + title?: string; + ariaLabel?: string; + } + | { + src: string; + alt?: string; + href?: string; + title?: string; + srcSet?: string; + sizes?: string; + width?: number; + height?: number; + }; + +export interface LogoLoopProps { + logos: LogoItem[]; + speed?: number; + direction?: 'left' | 'right' | 'up' | 'down'; + width?: number | string; + logoHeight?: number; + gap?: number; + pauseOnHover?: boolean; + hoverSpeed?: number; + fadeOut?: boolean; + fadeOutColor?: string; + scaleOnHover?: boolean; + renderItem?: (item: LogoItem, key: React.Key) => React.ReactNode; + ariaLabel?: string; + className?: string; + style?: React.CSSProperties; +} + +const ANIMATION_CONFIG = { + SMOOTH_TAU: 0.25, + MIN_COPIES: 2, + COPY_HEADROOM: 2, +} as const; + +const toCssLength = (value?: number | string): string | undefined => + typeof value === 'number' ? `${value}px` : (value ?? undefined); + +const cx = (...parts: Array) => + parts.filter(Boolean).join(' '); + +const useResizeObserver = ( + callback: () => void, + elements: Array>, + dependencies: React.DependencyList, +) => { + useEffect(() => { + if (!window.ResizeObserver) { + const handleResize = () => callback(); + window.addEventListener('resize', handleResize); + callback(); + return () => window.removeEventListener('resize', handleResize); + } + + const observers = elements.map((ref) => { + if (!ref.current) return null; + const observer = new ResizeObserver(callback); + observer.observe(ref.current); + return observer; + }); + + callback(); + + return () => { + observers.forEach((observer) => observer?.disconnect()); + }; + }, dependencies); +}; + +const useImageLoader = ( + seqRef: React.RefObject, + onLoad: () => void, + dependencies: React.DependencyList, +) => { + useEffect(() => { + const images = seqRef.current?.querySelectorAll('img') ?? []; + + if (images.length === 0) { + onLoad(); + return; + } + + let remainingImages = images.length; + const handleImageLoad = () => { + remainingImages -= 1; + if (remainingImages === 0) { + onLoad(); + } + }; + + images.forEach((img) => { + const htmlImg = img as HTMLImageElement; + if (htmlImg.complete) { + handleImageLoad(); + } else { + htmlImg.addEventListener('load', handleImageLoad, { once: true }); + htmlImg.addEventListener('error', handleImageLoad, { once: true }); + } + }); + + return () => { + images.forEach((img) => { + img.removeEventListener('load', handleImageLoad); + img.removeEventListener('error', handleImageLoad); + }); + }; + }, dependencies); +}; + +const useAnimationLoop = ( + trackRef: React.RefObject, + targetVelocity: number, + seqWidth: number, + seqHeight: number, + isHovered: boolean, + hoverSpeed: number | undefined, + isVertical: boolean, +) => { + const rafRef = useRef(null); + const lastTimestampRef = useRef(null); + const offsetRef = useRef(0); + const velocityRef = useRef(0); + + useEffect(() => { + const track = trackRef.current; + if (!track) return; + + const prefersReduced = + typeof window !== 'undefined' && + window.matchMedia && + window.matchMedia('(prefers-reduced-motion: reduce)').matches; + + const seqSize = isVertical ? seqHeight : seqWidth; + + if (seqSize > 0) { + offsetRef.current = ((offsetRef.current % seqSize) + seqSize) % seqSize; + const transformValue = isVertical + ? `translate3d(0, ${-offsetRef.current}px, 0)` + : `translate3d(${-offsetRef.current}px, 0, 0)`; + track.style.transform = transformValue; + } + + if (prefersReduced) { + track.style.transform = isVertical + ? 'translate3d(0, 0, 0)' + : 'translate3d(0, 0, 0)'; + return () => { + lastTimestampRef.current = null; + }; + } + + const animate = (timestamp: number) => { + if (lastTimestampRef.current === null) { + lastTimestampRef.current = timestamp; + } + + const deltaTime = + Math.max(0, timestamp - lastTimestampRef.current) / 1000; + lastTimestampRef.current = timestamp; + + const target = + isHovered && hoverSpeed !== undefined ? hoverSpeed : targetVelocity; + + const easingFactor = + 1 - Math.exp(-deltaTime / ANIMATION_CONFIG.SMOOTH_TAU); + velocityRef.current += (target - velocityRef.current) * easingFactor; + + if (seqSize > 0) { + let nextOffset = offsetRef.current + velocityRef.current * deltaTime; + nextOffset = ((nextOffset % seqSize) + seqSize) % seqSize; + offsetRef.current = nextOffset; + + const transformValue = isVertical + ? `translate3d(0, ${-offsetRef.current}px, 0)` + : `translate3d(${-offsetRef.current}px, 0, 0)`; + track.style.transform = transformValue; + } + + rafRef.current = requestAnimationFrame(animate); + }; + + rafRef.current = requestAnimationFrame(animate); + + return () => { + if (rafRef.current !== null) { + cancelAnimationFrame(rafRef.current); + rafRef.current = null; + } + lastTimestampRef.current = null; + }; + }, [targetVelocity, seqWidth, seqHeight, isHovered, hoverSpeed, isVertical]); +}; + +export const LogoLoop = React.memo( + ({ + logos, + speed = 120, + direction = 'left', + width = '100%', + logoHeight = 28, + gap = 32, + pauseOnHover, + hoverSpeed, + fadeOut = false, + fadeOutColor, + scaleOnHover = false, + renderItem, + ariaLabel = 'Partner logos', + className, + style, + }) => { + const containerRef = useRef(null); + const trackRef = useRef(null); + const seqRef = useRef(null); + + const [seqWidth, setSeqWidth] = useState(0); + const [seqHeight, setSeqHeight] = useState(0); + const [copyCount, setCopyCount] = useState( + ANIMATION_CONFIG.MIN_COPIES, + ); + const [isHovered, setIsHovered] = useState(false); + + const effectiveHoverSpeed = useMemo(() => { + if (hoverSpeed !== undefined) return hoverSpeed; + if (pauseOnHover === true) return 0; + if (pauseOnHover === false) return undefined; + return 0; + }, [hoverSpeed, pauseOnHover]); + + const isVertical = direction === 'up' || direction === 'down'; + + const targetVelocity = useMemo(() => { + const magnitude = Math.abs(speed); + let directionMultiplier: number; + if (isVertical) { + directionMultiplier = direction === 'up' ? 1 : -1; + } else { + directionMultiplier = direction === 'left' ? 1 : -1; + } + const speedMultiplier = speed < 0 ? -1 : 1; + return magnitude * directionMultiplier * speedMultiplier; + }, [speed, direction, isVertical]); + + const updateDimensions = useCallback(() => { + const containerWidth = containerRef.current?.clientWidth ?? 0; + const sequenceRect = seqRef.current?.getBoundingClientRect?.(); + const sequenceWidth = sequenceRect?.width ?? 0; + const sequenceHeight = sequenceRect?.height ?? 0; + if (isVertical) { + const parentHeight = + containerRef.current?.parentElement?.clientHeight ?? 0; + if (containerRef.current && parentHeight > 0) { + const targetHeight = Math.ceil(parentHeight); + if (containerRef.current.style.height !== `${targetHeight}px`) + containerRef.current.style.height = `${targetHeight}px`; + } + if (sequenceHeight > 0) { + setSeqHeight(Math.ceil(sequenceHeight)); + const viewport = + containerRef.current?.clientHeight ?? + parentHeight ?? + sequenceHeight; + const copiesNeeded = + Math.ceil(viewport / sequenceHeight) + + ANIMATION_CONFIG.COPY_HEADROOM; + setCopyCount(Math.max(ANIMATION_CONFIG.MIN_COPIES, copiesNeeded)); + } + } else if (sequenceWidth > 0) { + setSeqWidth(Math.ceil(sequenceWidth)); + const copiesNeeded = + Math.ceil(containerWidth / sequenceWidth) + + ANIMATION_CONFIG.COPY_HEADROOM; + setCopyCount(Math.max(ANIMATION_CONFIG.MIN_COPIES, copiesNeeded)); + } + }, [isVertical]); + + useResizeObserver( + updateDimensions, + [containerRef, seqRef], + [logos, gap, logoHeight, isVertical], + ); + + useImageLoader(seqRef, updateDimensions, [ + logos, + gap, + logoHeight, + isVertical, + ]); + + useAnimationLoop( + trackRef, + targetVelocity, + seqWidth, + seqHeight, + isHovered, + effectiveHoverSpeed, + isVertical, + ); + + const cssVariables = useMemo( + () => + ({ + '--logoloop-gap': `${gap}px`, + '--logoloop-logoHeight': `${logoHeight}px`, + ...(fadeOutColor && { '--logoloop-fadeColor': fadeOutColor }), + }) as React.CSSProperties, + [gap, logoHeight, fadeOutColor], + ); + + const rootClasses = useMemo( + () => + cx( + 'relative group', + isVertical + ? 'overflow-hidden h-full inline-block' + : 'overflow-x-hidden', + '[--logoloop-gap:32px]', + '[--logoloop-logoHeight:28px]', + '[--logoloop-fadeColorAuto:#ffffff]', + 'dark:[--logoloop-fadeColorAuto:#0b0b0b]', + scaleOnHover && 'py-[calc(var(--logoloop-logoHeight)*0.1)]', + className, + ), + [isVertical, scaleOnHover, className], + ); + + const handleMouseEnter = useCallback(() => { + if (effectiveHoverSpeed !== undefined) setIsHovered(true); + }, [effectiveHoverSpeed]); + const handleMouseLeave = useCallback(() => { + if (effectiveHoverSpeed !== undefined) setIsHovered(false); + }, [effectiveHoverSpeed]); + + const renderLogoItem = useCallback( + (item: LogoItem, key: React.Key) => { + if (renderItem) { + return ( +
  • + {renderItem(item, key)} +
  • + ); + } + + const isNodeItem = 'node' in item; + + const content = isNodeItem ? ( + + {(item as any).node} + + ) : ( + {(item + ); + + const itemAriaLabel = isNodeItem + ? ((item as any).ariaLabel ?? (item as any).title) + : ((item as any).alt ?? (item as any).title); + + const inner = (item as any).href ? ( + + {content} + + ) : ( + content + ); + + return ( +
  • + {inner} +
  • + ); + }, + [isVertical, scaleOnHover, renderItem], + ); + + const logoLists = useMemo( + () => + Array.from({ length: copyCount }, (_, copyIndex) => ( +
      0} + ref={copyIndex === 0 ? seqRef : undefined} + > + {logos.map((item, itemIndex) => + renderLogoItem(item, `${copyIndex}-${itemIndex}`), + )} +
    + )), + [copyCount, logos, renderLogoItem, isVertical], + ); + + const containerStyle = useMemo( + (): React.CSSProperties => ({ + width: isVertical + ? toCssLength(width) === '100%' + ? undefined + : toCssLength(width) + : (toCssLength(width) ?? '100%'), + ...cssVariables, + ...style, + }), + [width, cssVariables, style, isVertical], + ); + + return ( +
    + {fadeOut && ( + <> + {isVertical ? ( + <> +
    +
    + + ) : ( + <> +
    +
    + + )} + + )} + +
    + {logoLists} +
    +
    + ); + }, +); + +LogoLoop.displayName = 'LogoLoop'; + +export default LogoLoop; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index eaaa4c7e2..983ee288f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -103,6 +103,9 @@ importers: '@supabase/supabase-js': specifier: ^2.49.3 version: 2.49.3 + '@tailwindcss/cli': + specifier: 4.1.18 + version: 4.1.18 '@tanstack/query-sync-storage-persister': specifier: 5.66.11 version: 5.66.11 @@ -390,7 +393,7 @@ importers: specifier: workspace:* version: link:../../services/realtime '@op/styles': - specifier: workspace:^ + specifier: workspace:* version: link:../../packages/styles '@op/supabase': specifier: workspace:* @@ -790,6 +793,9 @@ importers: packages/styles: devDependencies: + '@tailwindcss/cli': + specifier: 4.1.18 + version: 4.1.18 '@tailwindcss/postcss': specifier: 4.1.18 version: 4.1.18 @@ -4375,6 +4381,10 @@ packages: '@swc/helpers@0.5.15': resolution: {integrity: sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==} + '@tailwindcss/cli@4.1.18': + resolution: {integrity: sha512-sMZ+lZbDyxwjD2E0L7oRUjJ01Ffjtme5OtjvvnC+cV4CEDcbqzbp25TCpxHj6kWLU9+DlqJOiNgSOgctC2aZmg==} + hasBin: true + '@tailwindcss/node@4.1.18': resolution: {integrity: sha512-DoR7U1P7iYhw16qJ49fgXUlry1t4CpXeErJHnQ44JgTSKMaZUdf17cfn5mHchfJ4KRBZRFA/Coo+MUF5+gOaCQ==} @@ -11303,7 +11313,6 @@ snapshots: '@parcel/watcher-win32-arm64': 2.5.1 '@parcel/watcher-win32-ia32': 2.5.1 '@parcel/watcher-win32-x64': 2.5.1 - optional: true '@petamoriken/float16@3.9.3': optional: true @@ -13047,6 +13056,16 @@ snapshots: dependencies: tslib: 2.8.1 + '@tailwindcss/cli@4.1.18': + dependencies: + '@parcel/watcher': 2.5.1 + '@tailwindcss/node': 4.1.18 + '@tailwindcss/oxide': 4.1.18 + enhanced-resolve: 5.18.4 + mri: 1.2.0 + picocolors: 1.1.1 + tailwindcss: 4.1.18 + '@tailwindcss/node@4.1.18': dependencies: '@jridgewell/remapping': 2.3.5 @@ -14676,8 +14695,7 @@ snapshots: detect-indent@7.0.1: {} - detect-libc@1.0.3: - optional: true + detect-libc@1.0.3: {} detect-libc@2.1.2: {} @@ -16532,8 +16550,7 @@ snapshots: react: 19.2.1 react-dom: 19.2.1(react@19.2.1) - mri@1.2.0: - optional: true + mri@1.2.0: {} mrmime@2.0.1: {} @@ -16640,8 +16657,7 @@ snapshots: lower-case: 2.0.2 tslib: 2.8.1 - node-addon-api@7.1.1: - optional: true + node-addon-api@7.1.1: {} node-domexception@1.0.0: {} diff --git a/turbo.json b/turbo.json index ffbbf2ea8..533c76f15 100644 --- a/turbo.json +++ b/turbo.json @@ -8,7 +8,8 @@ ".next/**", "!.next/cache/**", "storybook-static/**", - "build/**" + "build/**", + "dist/**" ] }, "migrate": {