Semantika V1 — minimalist AI reflection partner (MVP) (#4)

Chat-first onboarding, model-driven paywall, Apple/Google subscriptions,
AWS CDK infrastructure, curated knowledge base, store compliance docs.
This commit is contained in:
Erik Svensson
2026-08-04 13:24:26 +02:00
committed by GitHub
210 changed files with 15149 additions and 17994 deletions
-3
View File
@@ -1,3 +0,0 @@
VITE_SUPABASE_PROJECT_ID="teeojvlqulhghditoakc"
VITE_SUPABASE_PUBLISHABLE_KEY="eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6InRlZW9qdmxxdWxoZ2hkaXRvYWtjIiwicm9sZSI6ImFub24iLCJpYXQiOjE3Njk1NTIzMDksImV4cCI6MjA4NTEyODMwOX0.3F29aAE33-RCoitlcTrka0F6RVlpeuagTqukhWzZ-R4"
VITE_SUPABASE_URL="https://teeojvlqulhghditoakc.supabase.co"
-1
View File
@@ -1 +0,0 @@
VITE_PAYMENTS_CLIENT_TOKEN="pk_test_51Tq6Vp9H9OVn8PZMfPUUv5mz2MeWgxIvfhKu8dmDfwDKakunkzBSrEBBLjWMOloDKgCzaiH2PWbkM4tsmUB6G9af00SwDXu38K"
-1
View File
@@ -1 +0,0 @@
VITE_PAYMENTS_CLIENT_TOKEN="pk_live_51Ts1x1FbBrMsTJqN2FqOd0Ju5sgiItLFiggiEzmt6I8208c8bZO8wVomKFXR5dZ42DpX2cYApMxzs0DCPWxlMFQI00qj4a0KU5"
+21
View File
@@ -0,0 +1,21 @@
name: CI
on:
pull_request:
push:
branches: [main]
jobs:
check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- run: npm ci
- run: npm run lint
- run: npm run format:check
- run: npm run typecheck
- run: npm test
+28
View File
@@ -0,0 +1,28 @@
name: Deploy
on:
push:
branches: [main]
concurrency: deploy
permissions:
id-token: write
contents: read
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- run: npm ci
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: ${{ secrets.AWS_DEPLOY_ROLE_ARN }}
aws-region: ${{ vars.AWS_REGION || 'eu-north-1' }}
- run: npx cdk deploy --require-approval never
working-directory: infra
+9 -22
View File
@@ -1,24 +1,11 @@
# Logs
logs
node_modules/
dist/
build/
.expo/
cdk.out/
coverage/
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
.env
.env.*
!.env.example
+7
View File
@@ -0,0 +1,7 @@
node_modules/
dist/
build/
.expo/
cdk.out/
coverage/
package-lock.json
+5
View File
@@ -0,0 +1,5 @@
{
"singleQuote": true,
"trailingComma": "all",
"printWidth": 100
}
+89
View File
@@ -0,0 +1,89 @@
# Launch checklist — closed beta
The path from this repo to 2050 test users. Everything below is
configuration and store work; the code is done (see Definition of Done in
the README).
## 1. AWS
- [ ] `cd infra && npx cdk bootstrap` (once per account/region).
- [ ] `npx cdk deploy` — note the outputs: `ApiUrl`, `UserPoolId`,
`UserPoolClientId`, `CognitoDomain`.
- [ ] Fill the `semantika/app` secret in Secrets Manager:
`OPENAI_API_KEY` (required for chat), `APPLE_SHARED_SECRET` and
`GOOGLE_SERVICE_ACCOUNT_JSON` (required before purchases work).
- [ ] Run `db/migrations/001_init.sql` against the Aurora cluster
(credentials in the RDS-managed secret; connect via a bastion or the
RDS query editor).
- [ ] Smoke test: `curl <ApiUrl>/suggestions` returns the starter list.
## 2. Sign-in
- [ ] Email sign-in works out of the box (Cognito hosted UI).
- [ ] Apple: create a Services ID + key in the Apple Developer portal, then
redeploy with CDK context `appleTeamId`, `appleKeyId`,
`applePrivateKeySecretName`.
- [ ] Google: create an OAuth client in Google Cloud, then redeploy with
`googleClientId`, `googleClientSecret`.
- [ ] For the beta, email-only is acceptable — Apple/Google can land in a
later build. Note: Apple's review requires Sign in with Apple if
other third-party logins are offered, so enable it before public
App Store release.
## 3. App configuration
- [ ] Put the CDK outputs into `apps/mobile/app.json``extra`
(`apiUrl`, `cognitoDomain`, `cognitoClientId`).
- [ ] Keep product ids as `semantika_monthly` / `semantika_yearly`.
## 4. Stores
- [ ] App Store Connect: create the app (bundle id `com.semantika.app`),
add auto-renewable subscriptions `semantika_monthly` ($5.99/month)
and `semantika_yearly` ($49.99/year) in one subscription group.
Generate the App-Specific Shared Secret → `APPLE_SHARED_SECRET`.
- [ ] Play Console: create the app (package `com.semantika.app`), add the
two subscriptions with the same ids and prices. Create a service
account with Play Developer API access → its JSON key becomes
`GOOGLE_SERVICE_ACCOUNT_JSON`.
- [ ] Store copy: use `docs/store/listing.md` (already written to the
honest-claims rule — inspiration, not proven effects; reflection
partner, not therapy).
- [ ] Fill the [PLACEHOLDERS] in `docs/store/privacy-policy.md` and
`docs/store/terms-of-service.md`, host them on a public URL (GitHub
Pages is enough), and set the URLs in both store consoles.
- [ ] App Privacy / Data safety forms: mappings are in
`docs/store/listing.md`. In-app account deletion (App Store 5.1.1)
is implemented: Account → Delete account.
## 5. Builds
- [ ] `cd apps/mobile && npx eas build --profile preview --platform all`
(in-app purchases require a real build, not Expo Go).
- [ ] iOS: distribute via TestFlight (internal, then external testers).
- [ ] Android: distribute via Play Console internal testing track.
- [ ] Purchases in test: use TestFlight sandbox accounts / Play license
testers — no real charges.
## 6. CI/CD
- [ ] Create the GitHub OIDC deploy role in AWS; set repo secret
`AWS_DEPLOY_ROLE_ARN` (and optionally the `AWS_REGION` variable).
- [ ] Merge this branch to `main` — CI runs lint/typecheck/tests,
`deploy.yml` deploys the stack automatically.
## 7. The beta itself (2050 users)
Measure before building anything new:
- **Do they come back?** — active users over time (`usage` activity).
- **How deep do dialogues go?** — `messages_used` distribution.
- **When do they upgrade?** — `subscription` transitions relative to
`created_at`.
- **Which starter questions create value?** — conversations are never
stored, so ask the testers directly (short interviews or a 3-question
survey beats analytics here).
Exit criteria for V1 → V2 decisions: a clear picture of retention, dialogue
depth, upgrade timing, and which conversation types resonate. Only then
open the Version 2 list.
+231 -48
View File
@@ -1,73 +1,256 @@
# Welcome to your Lovable project
# Semantika
## Project info
**Think Beyond Thought.**
**URL**: https://lovable.dev/projects/REPLACE_WITH_PROJECT_ID
The world's first NeuroSemantic conversation platform. Semantika helps people
explore how they create meaning, interpret their experiences, and communicate
with themselves and others — through structured conversations inspired by
neurosemantic principles.
## How can I edit this code?
## Brand
There are several ways of editing your application.
- **Core promise** — Discover the meaning behind your thinking.
- **Mission** — Helping people create better meaning.
- **Vision** — To make NeuroSemantic thinking accessible to everyone.
**Use Lovable**
**Positioning.** Semantika is not therapy, not self-help, and not a course.
It is an intelligent reflection partner that uses neurosemantic models to
help the user explore thinking patterns, communication, and perspective.
Instead of quick advice, Semantika starts by helping the user explore how
they interpret their situation — supporting reflection and
perspective-taking, not delivering finished answers.
Simply visit the [Lovable Project](https://lovable.dev/projects/REPLACE_WITH_PROJECT_ID) and start prompting.
**Honest claims.** Neurosemantics and NLP are presented as inspiration and
models for reflection, never as scientifically proven methods. This is
enforced in the system instructions and the knowledge base; copy in the app
and the stores must follow the same rule.
Changes made via Lovable will be committed automatically to this repo.
**Tone.** Never judging, dramatic, overenthusiastic, or preaching. Always
calm, curious, clear, respectful, structured, thoughtful.
**Use your preferred IDE**
**Design.** Clinical, Scandinavian, quiet, intelligent, premium, minimal.
Off-white background, near-black text, one dark blue-green accent. Generous
white space. No animations, no gradients. The rule: if something can be
removed without reducing user value, remove it.
If you want to work locally using your own IDE, you can clone this repo and push changes. Pushed changes will also be reflected in Lovable.
**Product principle.** One user. One conversation. One analysis. One
recommendation. That is the whole product.
The only requirement is having Node.js & npm installed - [install with nvm](https://github.com/nvm-sh/nvm#installing-and-updating)
## System overview
Follow these steps:
```sh
# Step 1: Clone the repository using the project's Git URL.
git clone <YOUR_GIT_URL>
# Step 2: Navigate to the project directory.
cd <YOUR_PROJECT_NAME>
# Step 3: Install the necessary dependencies.
npm i
# Step 4: Start the development server with auto-reloading and an instant preview.
npm run dev
```
apps/mobile Expo / React Native app (two views: Chat, Paywall)
services/api One Lambda backend (chat, usage, subscription verification)
infra AWS CDK stack (the entire cloud environment)
db/migrations SQL schema (two tables: users, usage)
```
**Edit a file directly in GitHub**
A new developer should understand the whole system in under an hour. Every
piece exists for a reason; if a feature does not make the core product better,
it is not built.
- Navigate to the desired file(s).
- Click the "Edit" button (pencil icon) at the top right of the file view.
- Make your changes and commit the changes.
### Request flow
**Use GitHub Codespaces**
1. The user lands directly in the chat — no registration before the first
question. Guests are identified by an app-generated device id
(`POST /guest/chat`); dynamic conversation starters come from
`GET /suggestions` (both public routes). The suggestions live in
`services/api/suggestions.json` and can be updated with a deploy — no
app release needed.
2. Requests go through **API Gateway** to the single **Lambda**; signed-in
users use `POST /chat` with a **Cognito** JWT (Apple / Google / email via
the hosted UI).
3. The Lambda calls the **OpenAI Responses API** with the Markdown knowledge
base (`services/api/knowledge/`) as system instructions: neurosemantic
models, communication models, the conversation guide, reflection
exercises, and a question library. V1 invests in prompt design quality,
not infrastructure complexity. The model returns structured output: a
reply plus an `analysis_ready` flag.
4. Sign-in happens at the paywall, since a purchase must attach to an
account. Usage counters live in **PostgreSQL** (Aurora Serverless v2).
- Navigate to the main page of your repository.
- Click on the "Code" button (green button) near the top right.
- Select the "Codespaces" tab.
- Click on "New codespace" to launch a new Codespace environment.
- Edit files directly within the Codespace and commit and push your changes once you're done.
### The intelligent paywall
## What technologies are used for this project?
**Monetization philosophy.** The goal is not to interrupt the conversation
— the goal is to build trust. The free experience should make the user feel
understood, respected, and curious to continue; only when the conversation
has reached a meaningful point is Premium introduced. The rule given to the
model, verbatim: _"Never manufacture suspense. Create genuine curiosity by
helping the user reach a meaningful insight, then offer a deeper level of
analysis in Premium."_
This project is built with:
The paywall is not a hardcoded message count. Free conversations run in
_discovery mode_: the model asks relevant follow-up questions, names
patterns, shows understanding, and helps the user reach a meaningful
insight of their own. When the conversation has reached that point and the
natural next step is a complete analysis, a structured framework, a
personalised strategy, practical exercises, or a step-by-step action plan,
the model pauses immediately _before_ delivering it — never mid-sentence,
never in the middle of an explanation — signals `analysis_ready`, and
writes a calm transition: "I think I understand the core pattern behind
what you've described. There are a few recurring themes that stand out, and
I have a structured way of working through them with you. Unlock Premium to
continue with the full analysis and your personalised action plan."
- Vite
- TypeScript
- React
- shadcn-ui
- Tailwind CSS
**Product feeling.** Every interaction should leave the user thinking
"this understands me". Every Premium conversion should feel like "I
genuinely want to continue this conversation" — never "they stopped me just
to make me pay". Manufactured urgency, emotional pressure and fake
readiness are explicitly forbidden in the instructions.
## How can I deploy this project?
On unlock, the app resends the transcript; premium mode then delivers the
full analysis, recommended strategies and concrete exercises immediately,
and the dialogue continues without restriction.
Simply open [Lovable](https://lovable.dev/projects/REPLACE_WITH_PROJECT_ID) and click on Share -> Publish.
A generous `MESSAGE_CAP` (default 200 per 30 days) exists purely as an
abuse backstop for the free tier — it is not the paywall.
## Can I connect a custom domain to my Lovable project?
Conversations are never stored server-side; the client holds them in memory
and sends the running transcript with each request. The database stores the
absolute minimum: `users` (id, email, auth_provider, subscription,
created_at) and `usage` (messages_used, last_reset). No profiling, no
training on user data. Error logging is anonymized (no message content).
Yes, you can!
### Payments
To connect a domain, navigate to Project > Settings > Domains and click Connect Domain.
Subscription logic is shared (`services/api/src/subscription/`); the payment
provider differs per platform behind one `PaymentProvider` interface:
Read more here: [Setting up a custom domain](https://docs.lovable.dev/features/custom-domain#custom-domain)
- **iOS** — In-App Purchase; the backend verifies the app receipt with Apple.
- **Android** — Google Play Billing; the backend verifies the purchase token
with the Play Developer API.
- **Web (future)** — a Stripe adapter slots into the same interface.
Plans: Monthly and Yearly. Nothing else.
**Pricing.** $5.99/month, $49.99/year (≈30 % below the monthly rate).
Premium includes unlimited conversations, unlimited analyses, personalised
guidance, and future feature updates. Prices are configured in App Store
Connect / Play Console on the products `semantika_monthly` and
`semantika_yearly`; the paywall fetches localized prices from the store and
falls back to these defaults until the store answers.
## Getting started
```sh
npm install # installs all workspaces
npm run lint
npm run typecheck
npm test
```
### Mobile app
```sh
cd apps/mobile
npm start # Expo dev server
```
Fill in `extra` in `app.json` (API URL, Cognito domain and client id) from
the CDK stack outputs. In-app purchases require a development build
(`expo run:ios` / `expo run:android`), not Expo Go.
### Backend + infrastructure
```sh
cd infra
npx cdk deploy
```
After the first deploy:
1. Put values into the `semantika/app` secret in Secrets Manager:
`OPENAI_API_KEY`, `APPLE_SHARED_SECRET`, `GOOGLE_SERVICE_ACCOUNT_JSON`.
2. Run `db/migrations/001_init.sql` against the cluster (credentials are in
the RDS-managed secret).
3. Optional: enable Apple/Google sign-in by passing CDK context
(`googleClientId`, `googleClientSecret`, `appleTeamId`, `appleKeyId`,
`applePrivateKeySecretName`). Email sign-in works out of the box.
### CI/CD
GitHub Actions: `ci.yml` lints, type-checks and tests every PR;
`deploy.yml` deploys the CDK stack on every push to `main` (set the
`AWS_DEPLOY_ROLE_ARN` secret for OIDC). Store builds ship via EAS
(`eas build`, profiles in `apps/mobile/eas.json`).
The full path to the closed beta — AWS, stores, builds, testers — is in
[LAUNCH.md](LAUNCH.md).
## Security
- All traffic over HTTPS. Account routes require a Cognito JWT; the two
public routes (`/suggestions`, `/guest/chat`) carry no account data and
are bounded by the free-tier message cap.
- Secrets live in AWS Secrets Manager only — no API keys in the client.
- The Lambda runs in private subnets; the database is not publicly reachable.
## Product philosophy
People grow when they become more aware of how they create meaning,
interpret their experiences, and shape their decisions. Some users come
seeking change, structure or guidance; others are simply curious and want
to develop. Semantika never assumes the user is struggling — it starts from
the assumption that they want to grow, and meets them where they are.
**Human Experience Doctrine.** Every user should feel seen, respected,
understood, capable, and hopeful. The system must never create dependency
or give the impression that it alone has the answers; its purpose is to
strengthen the user's own ability to reflect and decide.
**Conversation philosophy.** Every conversation moves through four steps:
_acknowledge_ (show the meaning was understood, not just the words),
_explore_ (discover new perspectives together — no interrogating, no
over-analyzing), _lift_ (name resources and strengths with concrete,
credible praise — never generic compliments), and _challenge_ (leave the
user with at least one new thought, question, model, or direction).
**Personality.** A very experienced coach, a calm mentor, a skilled
teacher, a wise conversation partner — never a therapist, a salesperson, a
preacher, or a guru. Warmth, curiosity and structure over stage energy.
**Educational philosophy.** Semantika does not just answer — it teaches the
user how to reflect: to think more clearly, communicate better, understand
their own reactions, and ask better questions. Success means the user
gradually needs the tool less, because they build skills of their own.
Every free user should leave the app feeling that the system understood
their situation, that a concrete analysis is ready, and that the next step
is available in Premium — never that they were held back by an artificial
interruption.
Every new feature must justify itself. The allowed AWS surface is
API Gateway, Lambda, Cognito, S3, Secrets Manager and CloudWatch — and V1
does not even need S3. No Redis, no Kubernetes, no Kafka, no Elasticsearch,
no queues, no microservices. One backend. Maximal simplicity.
## Definition of Done
Version 1 is done when a user can:
1. Open the app.
2. Start a conversation immediately.
3. Feel seen and understood.
4. Receive a number of well-considered follow-up questions.
5. Reach a natural premium boundary.
6. Buy Premium.
7. Continue the conversation.
If a feature does not help the user reflect better, it is not built.
Version 1 must be small, fast, stable, and easy to maintain.
## Roadmap
Build a strong core product first; only then build around it.
- **Version 1 (this repo)** — Person ↔ Semantika. Nothing else.
- **Version 2 (not now)** — journal, saved insights, community, certified
coaches, courses, voice conversations. None of these are built in V1.
**Next step: a closed beta.** Put V1 in the hands of 2050 test users
before adding anything. The minimal data model already answers several of
the key questions — how many come back (`usage.last_reset` vs activity),
how deep dialogues go (`messages_used`), and when users upgrade
(`subscription` transitions). Which starter questions create the most value
requires asking testers directly, since conversations are never stored.
Anything beyond that must justify itself against the privacy rule.
+62
View File
@@ -0,0 +1,62 @@
import { useEffect, useState } from 'react';
import { StatusBar } from 'expo-status-bar';
import type { ChatMessage } from './src/api/client';
import { getAccessToken } from './src/auth/session';
import { ChatScreen } from './src/screens/ChatScreen';
import { PaywallScreen } from './src/screens/PaywallScreen';
/**
* Two views, no navigation library. The user lands directly in the chat —
* no registration before the first question. The backend decides the paywall
* moment (the analysis is ready); after unlock the conversation continues
* with the full analysis delivered immediately.
*/
export default function App() {
const [view, setView] = useState<'chat' | 'paywall'>('chat');
const [messages, setMessages] = useState<ChatMessage[]>([]);
const [paywalled, setPaywalled] = useState(false);
const [deliverAnalysis, setDeliverAnalysis] = useState(false);
const [signedIn, setSignedIn] = useState(false);
useEffect(() => {
getAccessToken().then((token) => setSignedIn(token !== null));
}, []);
function resetToSignedOut() {
setSignedIn(false);
setMessages([]);
setPaywalled(false);
setDeliverAnalysis(false);
setView('chat');
}
return (
<>
<StatusBar style="dark" />
{view === 'chat' && (
<ChatScreen
messages={messages}
setMessages={setMessages}
paywalled={paywalled}
onPaywallTriggered={() => setPaywalled(true)}
onContinueWithPremium={() => setView('paywall')}
deliverAnalysis={deliverAnalysis}
onAnalysisDelivered={() => setDeliverAnalysis(false)}
signedIn={signedIn}
onSignedOut={resetToSignedOut}
/>
)}
{view === 'paywall' && (
<PaywallScreen
onSubscribed={() => {
setSignedIn(true);
setPaywalled(false);
setDeliverAnalysis(true);
setView('chat');
}}
onDismiss={() => setView('chat')}
/>
)}
</>
);
}
+28
View File
@@ -0,0 +1,28 @@
{
"expo": {
"name": "Semantika",
"slug": "semantika",
"version": "1.0.0",
"orientation": "portrait",
"scheme": "semantika",
"userInterfaceStyle": "light",
"backgroundColor": "#F7F6F2",
"ios": {
"bundleIdentifier": "com.semantika.app",
"supportsTablet": false,
"usesAppleSignIn": true
},
"android": {
"package": "com.semantika.app"
},
"extra": {
"apiUrl": "https://REPLACE_WITH_API_URL",
"cognitoDomain": "https://REPLACE_WITH_PREFIX.auth.eu-north-1.amazoncognito.com",
"cognitoClientId": "REPLACE_WITH_CLIENT_ID",
"iosMonthlyProductId": "semantika_monthly",
"iosYearlyProductId": "semantika_yearly",
"androidMonthlyProductId": "semantika_monthly",
"androidYearlyProductId": "semantika_yearly"
}
}
}
+21
View File
@@ -0,0 +1,21 @@
{
"cli": {
"version": ">= 12.0.0",
"appVersionSource": "remote"
},
"build": {
"development": {
"developmentClient": true,
"distribution": "internal"
},
"preview": {
"distribution": "internal"
},
"production": {
"autoIncrement": true
}
},
"submit": {
"production": {}
}
}
+4
View File
@@ -0,0 +1,4 @@
import { registerRootComponent } from 'expo';
import App from './App';
registerRootComponent(App);
+27
View File
@@ -0,0 +1,27 @@
{
"name": "@semantika/mobile",
"version": "1.0.0",
"private": true,
"main": "index.ts",
"scripts": {
"start": "expo start",
"ios": "expo run:ios",
"android": "expo run:android",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"expo": "~52.0.0",
"expo-auth-session": "~6.0.0",
"expo-crypto": "~14.0.0",
"expo-secure-store": "~14.0.0",
"expo-status-bar": "~2.0.0",
"expo-web-browser": "~14.0.0",
"react": "18.3.1",
"react-native": "0.76.5",
"react-native-iap": "^12.15.7"
},
"devDependencies": {
"@types/react": "~18.3.12",
"typescript": "^5.7.2"
}
}
+107
View File
@@ -0,0 +1,107 @@
import * as Crypto from 'expo-crypto';
import * as SecureStore from 'expo-secure-store';
import { appConfig } from '../config';
import { getAccessToken } from '../auth/session';
export class ApiError extends Error {
constructor(
public readonly status: number,
public readonly code: string,
) {
super(`API error ${status}: ${code}`);
}
}
// Mirrors the backend contract in services/api/src/handler.ts.
export interface ChatMessage {
role: 'user' | 'assistant';
content: string;
}
export interface ChatReply {
reply: string;
/** True when the backend judged the analysis ready — the paywall moment. */
paywall: boolean;
}
export interface Me {
subscriptionStatus: 'free' | 'active';
messagesUsed: number;
messageCap: number;
}
const DEVICE_KEY = 'semantika.device';
/** Stable anonymous id so guests can chat before signing in. */
async function getDeviceId(): Promise<string> {
let id = await SecureStore.getItemAsync(DEVICE_KEY);
if (!id) {
id = Crypto.randomUUID();
await SecureStore.setItemAsync(DEVICE_KEY, id);
}
return id;
}
async function request<T>(
path: string,
init: RequestInit = {},
headers: Record<string, string> = {},
): Promise<T> {
const response = await fetch(`${appConfig.apiUrl}${path}`, {
...init,
headers: { 'Content-Type': 'application/json', ...headers },
});
if (!response.ok) {
let code = 'unknown';
try {
code = ((await response.json()) as { error?: string }).error ?? 'unknown';
} catch {
// keep 'unknown'
}
throw new ApiError(response.status, code);
}
return (await response.json()) as T;
}
async function authHeader(): Promise<Record<string, string>> {
const token = await getAccessToken();
if (!token) throw new ApiError(401, 'not_signed_in');
return { Authorization: `Bearer ${token}` };
}
export async function fetchSuggestions(): Promise<string[]> {
const data = await request<{ suggestions: string[] }>('/suggestions');
return data.suggestions;
}
/** Signed-in users chat via /chat; guests via /guest/chat with a device id. */
export async function sendChat(messages: ChatMessage[]): Promise<ChatReply> {
const body = { method: 'POST', body: JSON.stringify({ messages }) };
const token = await getAccessToken();
if (token) {
return request<ChatReply>('/chat', body, { Authorization: `Bearer ${token}` });
}
return request<ChatReply>('/guest/chat', body, { 'X-Device-Id': await getDeviceId() });
}
export async function fetchMe(): Promise<Me> {
return request<Me>('/me', {}, await authHeader());
}
/** Permanently deletes the account and its usage data (App Store 5.1.1). */
export async function deleteAccount(): Promise<void> {
await request<{ deleted: boolean }>('/me', { method: 'DELETE' }, await authHeader());
}
export async function verifyPurchase(body: {
platform: 'ios' | 'android';
productId: string;
receipt: string;
}): Promise<{ subscriptionStatus: 'free' | 'active' }> {
return request(
'/subscription/verify',
{ method: 'POST', body: JSON.stringify(body) },
await authHeader(),
);
}
+106
View File
@@ -0,0 +1,106 @@
import {
AuthRequest,
exchangeCodeAsync,
makeRedirectUri,
refreshAsync,
type DiscoveryDocument,
} from 'expo-auth-session';
import * as SecureStore from 'expo-secure-store';
import * as WebBrowser from 'expo-web-browser';
import { appConfig } from '../config';
WebBrowser.maybeCompleteAuthSession();
export type Provider = 'apple' | 'google' | 'email';
interface StoredSession {
accessToken: string;
refreshToken?: string;
expiresAt: number; // epoch ms
}
const STORE_KEY = 'semantika.session';
const discovery: DiscoveryDocument = {
authorizationEndpoint: `${appConfig.cognitoDomain}/oauth2/authorize`,
tokenEndpoint: `${appConfig.cognitoDomain}/oauth2/token`,
revocationEndpoint: `${appConfig.cognitoDomain}/oauth2/revoke`,
};
const redirectUri = makeRedirectUri({ scheme: 'semantika', path: 'redirect' });
/** Maps our provider names to Cognito hosted UI identity providers. */
const IDP: Record<Provider, string | undefined> = {
apple: 'SignInWithApple',
google: 'Google',
email: undefined, // Hosted UI's own email/password form
};
async function persist(session: StoredSession): Promise<void> {
await SecureStore.setItemAsync(STORE_KEY, JSON.stringify(session));
}
/** Signs in via the Cognito hosted UI with PKCE. Returns the session. */
export async function signIn(provider: Provider): Promise<StoredSession> {
const request = new AuthRequest({
clientId: appConfig.cognitoClientId,
redirectUri,
scopes: ['openid', 'email'],
usePKCE: true,
extraParams: IDP[provider] ? { identity_provider: IDP[provider] as string } : {},
});
const result = await request.promptAsync(discovery);
if (result.type !== 'success' || !result.params.code) {
throw new Error('Sign-in was cancelled');
}
const tokens = await exchangeCodeAsync(
{
clientId: appConfig.cognitoClientId,
code: result.params.code,
redirectUri,
extraParams: { code_verifier: request.codeVerifier ?? '' },
},
discovery,
);
const session: StoredSession = {
accessToken: tokens.accessToken,
refreshToken: tokens.refreshToken,
expiresAt: Date.now() + (tokens.expiresIn ?? 3600) * 1000,
};
await persist(session);
return session;
}
/** Returns a valid access token, refreshing if needed, or null if signed out. */
export async function getAccessToken(): Promise<string | null> {
const raw = await SecureStore.getItemAsync(STORE_KEY);
if (!raw) return null;
const session = JSON.parse(raw) as StoredSession;
if (Date.now() < session.expiresAt - 60_000) return session.accessToken;
if (!session.refreshToken) return null;
try {
const tokens = await refreshAsync(
{ clientId: appConfig.cognitoClientId, refreshToken: session.refreshToken },
discovery,
);
const refreshed: StoredSession = {
accessToken: tokens.accessToken,
refreshToken: tokens.refreshToken ?? session.refreshToken,
expiresAt: Date.now() + (tokens.expiresIn ?? 3600) * 1000,
};
await persist(refreshed);
return refreshed.accessToken;
} catch {
await signOut();
return null;
}
}
export async function signOut(): Promise<void> {
await SecureStore.deleteItemAsync(STORE_KEY);
}
+19
View File
@@ -0,0 +1,19 @@
import Constants from 'expo-constants';
const extra = (Constants.expoConfig?.extra ?? {}) as Record<string, string>;
function required(key: string): string {
const value = extra[key];
if (!value) throw new Error(`Missing "${key}" in app.json extra`);
return value;
}
export const appConfig = {
apiUrl: required('apiUrl'),
cognitoDomain: required('cognitoDomain'),
cognitoClientId: required('cognitoClientId'),
iosMonthlyProductId: required('iosMonthlyProductId'),
iosYearlyProductId: required('iosYearlyProductId'),
androidMonthlyProductId: required('androidMonthlyProductId'),
androidYearlyProductId: required('androidYearlyProductId'),
};
+112
View File
@@ -0,0 +1,112 @@
import { Platform } from 'react-native';
import {
endConnection,
getAvailablePurchases,
getSubscriptions,
initConnection,
requestSubscription,
type SubscriptionPurchase,
} from 'react-native-iap';
import { verifyPurchase } from '../api/client';
import { appConfig } from '../config';
export type Plan = 'monthly' | 'yearly';
export interface PlanPrices {
monthly: string;
yearly: string;
}
/** Shown until the store answers; real localized prices come from the store. */
export const DEFAULT_PRICES: PlanPrices = {
monthly: '$5.99 / month',
yearly: '$49.99 / year',
};
const platform: 'ios' | 'android' = Platform.OS === 'ios' ? 'ios' : 'android';
export function productIdFor(plan: Plan): string {
if (platform === 'ios') {
return plan === 'monthly' ? appConfig.iosMonthlyProductId : appConfig.iosYearlyProductId;
}
return plan === 'monthly' ? appConfig.androidMonthlyProductId : appConfig.androidYearlyProductId;
}
function receiptOf(purchase: SubscriptionPurchase): string {
// iOS: base64 app receipt. Android: Play Billing purchase token.
return platform === 'ios' ? purchase.transactionReceipt : (purchase.purchaseToken ?? '');
}
async function withConnection<T>(fn: () => Promise<T>): Promise<T> {
await initConnection();
try {
return await fn();
} finally {
await endConnection();
}
}
function priceOf(product: unknown): string | undefined {
if (!product) return undefined;
// iOS exposes localizedPrice; Android nests it in the first offer's pricing phases.
const p = product as {
localizedPrice?: string;
subscriptionOfferDetails?: {
pricingPhases?: { pricingPhaseList?: { formattedPrice?: string }[] };
}[];
};
return (
p.localizedPrice ??
p.subscriptionOfferDetails?.[0]?.pricingPhases?.pricingPhaseList?.[0]?.formattedPrice
);
}
/** Localized prices from the store, falling back to the defaults. */
export async function getPrices(): Promise<PlanPrices> {
try {
return await withConnection(async () => {
const products = await getSubscriptions({
skus: [productIdFor('monthly'), productIdFor('yearly')],
});
const find = (plan: Plan) => products.find((p) => p.productId === productIdFor(plan));
return {
monthly: priceOf(find('monthly')) ?? DEFAULT_PRICES.monthly,
yearly: priceOf(find('yearly')) ?? DEFAULT_PRICES.yearly,
};
});
} catch {
return DEFAULT_PRICES;
}
}
/**
* Runs the native purchase flow, then lets the backend verify the receipt
* with the store. Returns the resulting subscription status.
*/
export async function purchase(plan: Plan): Promise<'free' | 'active'> {
const productId = productIdFor(plan);
return withConnection(async () => {
await requestSubscription({ sku: productId });
const purchases = await getAvailablePurchases();
const match = purchases.find((p) => p.productId === productId);
if (!match) throw new Error('Purchase not found after transaction');
const result = await verifyPurchase({ platform, productId, receipt: receiptOf(match) });
return result.subscriptionStatus;
});
}
/** Re-checks existing purchases with the store ("Restore Purchase"). */
export async function restore(): Promise<'free' | 'active'> {
return withConnection(async () => {
const purchases = await getAvailablePurchases();
for (const p of purchases) {
const result = await verifyPurchase({
platform,
productId: p.productId,
receipt: receiptOf(p),
});
if (result.subscriptionStatus === 'active') return 'active';
}
return 'free';
});
}
+273
View File
@@ -0,0 +1,273 @@
import { useEffect, useRef, useState } from 'react';
import {
Alert,
FlatList,
KeyboardAvoidingView,
Platform,
Pressable,
StyleSheet,
Text,
TextInput,
View,
} from 'react-native';
import { deleteAccount, fetchSuggestions, sendChat, type ChatMessage } from '../api/client';
import { signOut } from '../auth/session';
import { colors, spacing, type } from '../theme';
interface Props {
messages: ChatMessage[];
setMessages: (messages: ChatMessage[]) => void;
/** The analysis is ready behind Premium; input is replaced by the unlock button. */
paywalled: boolean;
onPaywallTriggered: () => void;
onContinueWithPremium: () => void;
/** Set right after unlock: fetch the promised analysis automatically. */
deliverAnalysis: boolean;
onAnalysisDelivered: () => void;
/** Shows the quiet Account affordance (sign out / delete account). */
signedIn: boolean;
onSignedOut: () => void;
}
const SUGGESTIONS_SHOWN = 4;
/**
* The whole product: one heading, the conversation, a text field and send.
* The user lands here directly — no registration before the first question.
* Conversations live in memory only; nothing is stored.
*/
export function ChatScreen({
messages,
setMessages,
paywalled,
onPaywallTriggered,
onContinueWithPremium,
deliverAnalysis,
onAnalysisDelivered,
signedIn,
onSignedOut,
}: Props) {
const [suggestions, setSuggestions] = useState<string[]>([]);
const [draft, setDraft] = useState('');
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const listRef = useRef<FlatList<ChatMessage>>(null);
useEffect(() => {
fetchSuggestions()
.then((all) =>
setSuggestions([...all].sort(() => Math.random() - 0.5).slice(0, SUGGESTIONS_SHOWN)),
)
.catch(() => setSuggestions([]));
}, []);
// After unlocking Premium the transcript ends with the assistant's
// transition message; resend it so the backend delivers the full analysis.
useEffect(() => {
if (!deliverAnalysis || busy) return;
setBusy(true);
setError(null);
sendChat(messages)
.then(({ reply }) => {
setMessages([...messages, { role: 'assistant', content: reply }]);
onAnalysisDelivered();
})
.catch(() => setError('Something went wrong. Please try again.'))
.finally(() => setBusy(false));
}, [deliverAnalysis]);
async function send(content: string) {
const trimmed = content.trim();
if (!trimmed || busy || paywalled) return;
const next: ChatMessage[] = [...messages, { role: 'user', content: trimmed }];
setMessages(next);
setDraft('');
setBusy(true);
setError(null);
try {
const { reply, paywall } = await sendChat(next);
setMessages([...next, { role: 'assistant', content: reply }]);
if (paywall) onPaywallTriggered();
} catch {
setError('Something went wrong. Please try again.');
setMessages(messages);
setDraft(trimmed);
} finally {
setBusy(false);
}
}
const showSuggestions = messages.length === 0 && suggestions.length > 0;
// No menus, no settings view: account management lives in two native
// dialogs behind one quiet link (in-app deletion per App Store 5.1.1).
function openAccount() {
Alert.alert('Account', undefined, [
{
text: 'Sign out',
onPress: () => {
signOut().then(onSignedOut);
},
},
{
text: 'Delete account',
style: 'destructive',
onPress: () =>
Alert.alert(
'Delete account?',
'This permanently deletes your account and usage data. Conversations are never stored. An active subscription is cancelled in your App Store or Google Play settings.',
[
{
text: 'Delete',
style: 'destructive',
onPress: () => {
deleteAccount()
.then(() => signOut())
.then(onSignedOut)
.catch(() => setError('Something went wrong. Please try again.'));
},
},
{ text: 'Cancel', style: 'cancel' },
],
),
},
{ text: 'Cancel', style: 'cancel' },
]);
}
return (
<KeyboardAvoidingView
style={styles.container}
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
>
<Text style={styles.heading}>Semantika</Text>
{showSuggestions ? (
<View style={styles.suggestions}>
{suggestions.map((s) => (
<Pressable key={s} style={styles.suggestion} onPress={() => send(s)} disabled={busy}>
<Text style={styles.suggestionText}>{s}</Text>
</Pressable>
))}
</View>
) : null}
<FlatList
ref={listRef}
style={styles.list}
contentContainerStyle={styles.listContent}
data={messages}
keyExtractor={(_, index) => String(index)}
onContentSizeChange={() => listRef.current?.scrollToEnd({ animated: false })}
ListEmptyComponent={<Text style={styles.empty}>What would you like to explore today?</Text>}
renderItem={({ item }) => (
<View style={[styles.message, item.role === 'user' ? styles.user : styles.assistant]}>
<Text style={item.role === 'user' ? styles.userText : styles.assistantText}>
{item.content}
</Text>
</View>
)}
/>
{error ? <Text style={styles.error}>{error}</Text> : null}
{paywalled ? (
<View style={styles.inputRow}>
<Pressable style={styles.unlock} onPress={onContinueWithPremium}>
<Text style={styles.unlockText}>Continue with Premium</Text>
</Pressable>
</View>
) : (
<View style={styles.inputRow}>
<TextInput
style={styles.input}
value={draft}
onChangeText={setDraft}
placeholder="Write a message"
placeholderTextColor={colors.textMuted}
multiline
editable={!busy}
/>
<Pressable
style={[styles.send, (!draft.trim() || busy) && styles.sendDisabled]}
onPress={() => send(draft)}
disabled={!draft.trim() || busy}
>
<Text style={styles.sendText}>{busy ? '…' : 'Send'}</Text>
</Pressable>
</View>
)}
{signedIn ? (
<Pressable style={styles.accountLink} onPress={openAccount}>
<Text style={styles.accountText}>Account</Text>
</Pressable>
) : null}
</KeyboardAvoidingView>
);
}
const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: colors.background },
heading: {
...type.heading,
textAlign: 'center',
paddingTop: spacing.xxl,
paddingBottom: spacing.m,
},
suggestions: { paddingHorizontal: spacing.l, gap: spacing.s, marginBottom: spacing.m },
suggestion: {
backgroundColor: colors.surface,
borderColor: colors.border,
borderWidth: 1,
borderRadius: 10,
paddingVertical: spacing.s + 2,
paddingHorizontal: spacing.m,
},
suggestionText: { ...type.body, color: colors.accent },
list: { flex: 1 },
listContent: { paddingHorizontal: spacing.l, paddingBottom: spacing.m, gap: spacing.s },
empty: { ...type.body, color: colors.textMuted, textAlign: 'center', marginTop: spacing.xl },
message: { maxWidth: '85%', borderRadius: 12, padding: spacing.m },
user: { alignSelf: 'flex-end', backgroundColor: colors.accent },
assistant: {
alignSelf: 'flex-start',
backgroundColor: colors.surface,
borderColor: colors.border,
borderWidth: 1,
},
userText: { ...type.body, color: colors.accentText },
assistantText: { ...type.body },
error: { ...type.caption, color: '#8A3B2E', textAlign: 'center', marginBottom: spacing.s },
inputRow: {
flexDirection: 'row',
alignItems: 'flex-end',
gap: spacing.s,
paddingHorizontal: spacing.l,
paddingBottom: spacing.m,
},
accountLink: { alignItems: 'center', paddingBottom: spacing.l },
accountText: { ...type.caption },
input: {
flex: 1,
...type.body,
backgroundColor: colors.surface,
borderColor: colors.border,
borderWidth: 1,
borderRadius: 12,
paddingHorizontal: spacing.m,
paddingVertical: spacing.s + 2,
maxHeight: 120,
},
send: {
backgroundColor: colors.accent,
borderRadius: 12,
paddingHorizontal: spacing.l,
paddingVertical: spacing.m - 2,
},
sendDisabled: { opacity: 0.4 },
sendText: { ...type.heading, color: colors.accentText },
unlock: {
flex: 1,
backgroundColor: colors.accent,
borderRadius: 12,
paddingVertical: spacing.m,
alignItems: 'center',
},
unlockText: { ...type.heading, color: colors.accentText },
});
+177
View File
@@ -0,0 +1,177 @@
import { useEffect, useState } from 'react';
import { Pressable, StyleSheet, Text, View } from 'react-native';
import { getAccessToken, signIn, type Provider } from '../auth/session';
import {
DEFAULT_PRICES,
getPrices,
purchase,
restore,
type Plan,
type PlanPrices,
} from '../purchases';
import { colors, spacing, type } from '../theme';
interface Props {
onSubscribed: () => void;
onDismiss: () => void;
}
const PREMIUM_INCLUDES = [
'Unlimited conversations',
'Unlimited analyses',
'Personalised guidance',
'Future feature updates',
];
/**
* Shown when the backend has a complete analysis ready. Guests sign in
* first (purchases must attach to an account), then choose a plan.
*/
export function PaywallScreen({ onSubscribed, onDismiss }: Props) {
const [signedIn, setSignedIn] = useState<boolean | null>(null);
const [prices, setPrices] = useState<PlanPrices>(DEFAULT_PRICES);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
getAccessToken().then((token) => setSignedIn(token !== null));
getPrices().then(setPrices);
}, []);
async function handleSignIn(provider: Provider) {
setBusy(true);
setError(null);
try {
await signIn(provider);
setSignedIn(true);
} catch {
setError('Sign-in did not complete. Please try again.');
} finally {
setBusy(false);
}
}
async function run(action: () => Promise<'free' | 'active'>) {
setBusy(true);
setError(null);
try {
const status = await action();
if (status === 'active') {
onSubscribed();
} else {
setError('No active subscription was found.');
}
} catch {
setError('The purchase did not complete. Please try again.');
} finally {
setBusy(false);
}
}
const buy = (plan: Plan) => run(() => purchase(plan));
return (
<View style={styles.container}>
<View style={styles.top}>
<Text style={styles.title}>Continue your journey</Text>
<Text style={styles.subtitle}>
Your full analysis and personalised action plan are ready. Premium is the natural next
step of the conversation you have already started.
</Text>
<View style={styles.includes}>
{PREMIUM_INCLUDES.map((item) => (
<Text key={item} style={styles.includesItem}>
{item}
</Text>
))}
</View>
</View>
<View style={styles.actions}>
{signedIn === false ? (
<>
<Pressable
style={styles.primaryButton}
onPress={() => handleSignIn('apple')}
disabled={busy}
>
<Text style={styles.primaryButtonText}>Continue with Apple</Text>
</Pressable>
<Pressable
style={styles.secondaryButton}
onPress={() => handleSignIn('google')}
disabled={busy}
>
<Text style={styles.secondaryButtonText}>Continue with Google</Text>
</Pressable>
<Pressable
style={styles.secondaryButton}
onPress={() => handleSignIn('email')}
disabled={busy}
>
<Text style={styles.secondaryButtonText}>Continue with Email</Text>
</Pressable>
</>
) : signedIn === true ? (
<>
<Pressable style={styles.primaryButton} onPress={() => buy('monthly')} disabled={busy}>
<Text style={styles.primaryButtonText}>Monthly</Text>
<Text style={styles.priceText}>{prices.monthly}</Text>
</Pressable>
<Pressable style={styles.primaryButton} onPress={() => buy('yearly')} disabled={busy}>
<Text style={styles.primaryButtonText}>Yearly</Text>
<Text style={styles.priceText}>{prices.yearly}</Text>
</Pressable>
<Pressable style={styles.textButton} onPress={() => run(restore)} disabled={busy}>
<Text style={styles.textButtonText}>Restore Purchase</Text>
</Pressable>
</>
) : null}
{error ? <Text style={styles.error}>{error}</Text> : null}
<Pressable style={styles.textButton} onPress={onDismiss} disabled={busy}>
<Text style={styles.dismissText}>Not now</Text>
</Pressable>
</View>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: colors.background,
paddingHorizontal: spacing.l,
justifyContent: 'space-between',
},
top: { marginTop: spacing.xxl * 2 },
title: { ...type.title, textAlign: 'center' },
subtitle: {
...type.body,
color: colors.textMuted,
textAlign: 'center',
marginTop: spacing.m,
},
actions: { marginBottom: spacing.xxl, gap: spacing.s },
primaryButton: {
backgroundColor: colors.accent,
paddingVertical: spacing.m,
borderRadius: 10,
alignItems: 'center',
},
primaryButtonText: { ...type.heading, color: colors.accentText },
priceText: { ...type.caption, color: colors.accentText, marginTop: 2 },
includes: { marginTop: spacing.l, gap: spacing.xs },
includesItem: { ...type.caption, textAlign: 'center' },
secondaryButton: {
backgroundColor: colors.surface,
borderColor: colors.border,
borderWidth: 1,
paddingVertical: spacing.m,
borderRadius: 10,
alignItems: 'center',
},
secondaryButtonText: { ...type.heading },
textButton: { alignItems: 'center', paddingVertical: spacing.m },
textButtonText: { ...type.body, color: colors.accent },
dismissText: { ...type.caption },
error: { ...type.caption, color: '#8A3B2E', textAlign: 'center' },
});
+29
View File
@@ -0,0 +1,29 @@
/**
* The entire visual language. Off-white, near-black, one dark blue-green
* accent. Generous whitespace. No animations, no gradients.
*/
export const colors = {
background: '#F7F6F2',
text: '#16181A',
textMuted: '#6E7472',
accent: '#14554C',
accentText: '#F7F6F2',
border: '#E4E2DB',
surface: '#FFFFFF',
} as const;
export const spacing = {
xs: 4,
s: 8,
m: 16,
l: 24,
xl: 40,
xxl: 64,
} as const;
export const type = {
title: { fontSize: 28, fontWeight: '600', color: colors.text } as const,
heading: { fontSize: 17, fontWeight: '600', color: colors.text } as const,
body: { fontSize: 17, lineHeight: 26, color: colors.text } as const,
caption: { fontSize: 13, lineHeight: 18, color: colors.textMuted } as const,
} as const;
+10
View File
@@ -0,0 +1,10 @@
{
"extends": "expo/tsconfig.base",
"compilerOptions": {
"strict": true,
"noUncheckedIndexedAccess": true,
"noImplicitOverride": true,
"forceConsistentCasingInFileNames": true
},
"include": ["index.ts", "App.tsx", "src"]
}
-1218
View File
@@ -1,1218 +0,0 @@
{
"lockfileVersion": 1,
"configVersion": 1,
"workspaces": {
"": {
"name": "vite_react_shadcn_ts",
"dependencies": {
"@hookform/resolvers": "^3.10.0",
"@radix-ui/react-accordion": "^1.2.11",
"@radix-ui/react-alert-dialog": "^1.1.14",
"@radix-ui/react-aspect-ratio": "^1.1.7",
"@radix-ui/react-avatar": "^1.1.10",
"@radix-ui/react-checkbox": "^1.3.2",
"@radix-ui/react-collapsible": "^1.1.11",
"@radix-ui/react-context-menu": "^2.2.15",
"@radix-ui/react-dialog": "^1.1.14",
"@radix-ui/react-dropdown-menu": "^2.1.15",
"@radix-ui/react-hover-card": "^1.1.14",
"@radix-ui/react-label": "^2.1.7",
"@radix-ui/react-menubar": "^1.1.15",
"@radix-ui/react-navigation-menu": "^1.2.13",
"@radix-ui/react-popover": "^1.1.14",
"@radix-ui/react-progress": "^1.1.7",
"@radix-ui/react-radio-group": "^1.3.7",
"@radix-ui/react-scroll-area": "^1.2.9",
"@radix-ui/react-select": "^2.2.5",
"@radix-ui/react-separator": "^1.1.7",
"@radix-ui/react-slider": "^1.3.5",
"@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-switch": "^1.2.5",
"@radix-ui/react-tabs": "^1.1.12",
"@radix-ui/react-toast": "^1.2.14",
"@radix-ui/react-toggle": "^1.1.9",
"@radix-ui/react-toggle-group": "^1.1.10",
"@radix-ui/react-tooltip": "^1.2.7",
"@stripe/react-stripe-js": "6.2.0",
"@stripe/stripe-js": "9.2.0",
"@supabase/supabase-js": "^2.93.1",
"@tanstack/react-query": "^5.83.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"date-fns": "^3.6.0",
"embla-carousel-react": "^8.6.0",
"input-otp": "^1.4.2",
"lucide-react": "^0.462.0",
"next-themes": "^0.3.0",
"react": "^18.3.1",
"react-day-picker": "^8.10.1",
"react-dom": "^18.3.1",
"react-hook-form": "^7.61.1",
"react-resizable-panels": "^2.1.9",
"react-router-dom": "^6.30.1",
"recharts": "^2.15.4",
"sonner": "^1.7.4",
"tailwind-merge": "^2.6.0",
"tailwindcss-animate": "^1.0.7",
"vaul": "^0.9.9",
"zod": "^3.25.76",
"zustand": "^5.0.10",
},
"devDependencies": {
"@eslint/js": "^9.32.0",
"@tailwindcss/typography": "^0.5.16",
"@testing-library/jest-dom": "^6.6.0",
"@testing-library/react": "^16.0.0",
"@types/node": "^22.16.5",
"@types/react": "^18.3.23",
"@types/react-dom": "^18.3.7",
"@vitejs/plugin-react-swc": "^3.11.0",
"autoprefixer": "^10.4.21",
"eslint": "^9.32.0",
"eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-react-refresh": "^0.4.20",
"globals": "^15.15.0",
"jsdom": "^20.0.3",
"lovable-tagger": "^1.1.13",
"postcss": "^8.5.6",
"tailwindcss": "^3.4.17",
"typescript": "^5.8.3",
"typescript-eslint": "^8.38.0",
"vite": "^5.4.19",
"vitest": "^3.2.4",
},
},
},
"packages": {
"@adobe/css-tools": ["@adobe/css-tools@4.4.4", "", {}, "sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg=="],
"@alloc/quick-lru": ["@alloc/quick-lru@5.2.0", "", {}, "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw=="],
"@babel/runtime": ["@babel/runtime@7.28.2", "", {}, "sha512-KHp2IflsnGywDjBWDkR9iEqiWSpc8GIi0lgTT3mOElT0PP1tG26P4tmFI2YvAdzgq9RGyoHZQEIEdZy6Ec5xCA=="],
"@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.0", "", { "os": "aix", "cpu": "ppc64" }, "sha512-O7vun9Sf8DFjH2UtqK8Ku3LkquL9SZL8OLY1T5NZkA34+wG3OQF7cl4Ql8vdNzM6fzBbYfLaiRLIOZ+2FOCgBQ=="],
"@esbuild/android-arm": ["@esbuild/android-arm@0.25.0", "", { "os": "android", "cpu": "arm" }, "sha512-PTyWCYYiU0+1eJKmw21lWtC+d08JDZPQ5g+kFyxP0V+es6VPPSUhM6zk8iImp2jbV6GwjX4pap0JFbUQN65X1g=="],
"@esbuild/android-arm64": ["@esbuild/android-arm64@0.25.0", "", { "os": "android", "cpu": "arm64" }, "sha512-grvv8WncGjDSyUBjN9yHXNt+cq0snxXbDxy5pJtzMKGmmpPxeAmAhWxXI+01lU5rwZomDgD3kJwulEnhTRUd6g=="],
"@esbuild/android-x64": ["@esbuild/android-x64@0.25.0", "", { "os": "android", "cpu": "x64" }, "sha512-m/ix7SfKG5buCnxasr52+LI78SQ+wgdENi9CqyCXwjVR2X4Jkz+BpC3le3AoBPYTC9NHklwngVXvbJ9/Akhrfg=="],
"@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.25.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-mVwdUb5SRkPayVadIOI78K7aAnPamoeFR2bT5nszFUZ9P8UpK4ratOdYbZZXYSqPKMHfS1wdHCJk1P1EZpRdvw=="],
"@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.25.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-DgDaYsPWFTS4S3nWpFcMn/33ZZwAAeAFKNHNa1QN0rI4pUjgqf0f7ONmXf6d22tqTY+H9FNdgeaAa+YIFUn2Rg=="],
"@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.25.0", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-VN4ocxy6dxefN1MepBx/iD1dH5K8qNtNe227I0mnTRjry8tj5MRk4zprLEdG8WPyAPb93/e4pSgi1SoHdgOa4w=="],
"@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.25.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-mrSgt7lCh07FY+hDD1TxiTyIHyttn6vnjesnPoVDNmDfOmggTLXRv8Id5fNZey1gl/V2dyVK1VXXqVsQIiAk+A=="],
"@esbuild/linux-arm": ["@esbuild/linux-arm@0.25.0", "", { "os": "linux", "cpu": "arm" }, "sha512-vkB3IYj2IDo3g9xX7HqhPYxVkNQe8qTK55fraQyTzTX/fxaDtXiEnavv9geOsonh2Fd2RMB+i5cbhu2zMNWJwg=="],
"@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.25.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-9QAQjTWNDM/Vk2bgBl17yWuZxZNQIF0OUUuPZRKoDtqF2k4EtYbpyiG5/Dk7nqeK6kIJWPYldkOcBqjXjrUlmg=="],
"@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.25.0", "", { "os": "linux", "cpu": "ia32" }, "sha512-43ET5bHbphBegyeqLb7I1eYn2P/JYGNmzzdidq/w0T8E2SsYL1U6un2NFROFRg1JZLTzdCoRomg8Rvf9M6W6Gg=="],
"@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.25.0", "", { "os": "linux", "cpu": "none" }, "sha512-fC95c/xyNFueMhClxJmeRIj2yrSMdDfmqJnyOY4ZqsALkDrrKJfIg5NTMSzVBr5YW1jf+l7/cndBfP3MSDpoHw=="],
"@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.25.0", "", { "os": "linux", "cpu": "none" }, "sha512-nkAMFju7KDW73T1DdH7glcyIptm95a7Le8irTQNO/qtkoyypZAnjchQgooFUDQhNAy4iu08N79W4T4pMBwhPwQ=="],
"@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.25.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-NhyOejdhRGS8Iwv+KKR2zTq2PpysF9XqY+Zk77vQHqNbo/PwZCzB5/h7VGuREZm1fixhs4Q/qWRSi5zmAiO4Fw=="],
"@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.25.0", "", { "os": "linux", "cpu": "none" }, "sha512-5S/rbP5OY+GHLC5qXp1y/Mx//e92L1YDqkiBbO9TQOvuFXM+iDqUNG5XopAnXoRH3FjIUDkeGcY1cgNvnXp/kA=="],
"@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.25.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-XM2BFsEBz0Fw37V0zU4CXfcfuACMrppsMFKdYY2WuTS3yi8O1nFOhil/xhKTmE1nPmVyvQJjJivgDT+xh8pXJA=="],
"@esbuild/linux-x64": ["@esbuild/linux-x64@0.25.0", "", { "os": "linux", "cpu": "x64" }, "sha512-9yl91rHw/cpwMCNytUDxwj2XjFpxML0y9HAOH9pNVQDpQrBxHy01Dx+vaMu0N1CKa/RzBD2hB4u//nfc+Sd3Cw=="],
"@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.25.0", "", { "os": "none", "cpu": "arm64" }, "sha512-RuG4PSMPFfrkH6UwCAqBzauBWTygTvb1nxWasEJooGSJ/NwRw7b2HOwyRTQIU97Hq37l3npXoZGYMy3b3xYvPw=="],
"@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.25.0", "", { "os": "none", "cpu": "x64" }, "sha512-jl+qisSB5jk01N5f7sPCsBENCOlPiS/xptD5yxOx2oqQfyourJwIKLRA2yqWdifj3owQZCL2sn6o08dBzZGQzA=="],
"@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.25.0", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-21sUNbq2r84YE+SJDfaQRvdgznTD8Xc0oc3p3iW/a1EVWeNj/SdUCbm5U0itZPQYRuRTW20fPMWMpcrciH2EJw=="],
"@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.25.0", "", { "os": "openbsd", "cpu": "x64" }, "sha512-2gwwriSMPcCFRlPlKx3zLQhfN/2WjJ2NSlg5TKLQOJdV0mSxIcYNTMhk3H3ulL/cak+Xj0lY1Ym9ysDV1igceg=="],
"@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.25.0", "", { "os": "sunos", "cpu": "x64" }, "sha512-bxI7ThgLzPrPz484/S9jLlvUAHYMzy6I0XiU1ZMeAEOBcS0VePBFxh1JjTQt3Xiat5b6Oh4x7UC7IwKQKIJRIg=="],
"@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.25.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-ZUAc2YK6JW89xTbXvftxdnYy3m4iHIkDtK3CLce8wg8M2L+YZhIvO1DKpxrd0Yr59AeNNkTiic9YLf6FTtXWMw=="],
"@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.25.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-eSNxISBu8XweVEWG31/JzjkIGbGIJN/TrRoiSVZwZ6pkC6VX4Im/WV2cz559/TXLcYbcrDN8JtKgd9DJVIo8GA=="],
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.0", "", { "os": "win32", "cpu": "x64" }, "sha512-ZENoHJBxA20C2zFzh6AI4fT6RraMzjYw4xKWemRTRmRVtN9c5DcH9r/f2ihEkMjOW5eGgrwCslG/+Y/3bL+DHQ=="],
"@eslint-community/eslint-utils": ["@eslint-community/eslint-utils@4.7.0", "", { "dependencies": { "eslint-visitor-keys": "^3.4.3" }, "peerDependencies": { "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" } }, "sha512-dyybb3AcajC7uha6CvhdVRJqaKyn7w2YKqKyAN37NKYgZT36w+iRb0Dymmc5qEJ549c/S31cMMSFd75bteCpCw=="],
"@eslint-community/regexpp": ["@eslint-community/regexpp@4.12.1", "", {}, "sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ=="],
"@eslint/config-array": ["@eslint/config-array@0.21.0", "", { "dependencies": { "@eslint/object-schema": "^2.1.6", "debug": "^4.3.1", "minimatch": "^3.1.2" } }, "sha512-ENIdc4iLu0d93HeYirvKmrzshzofPw6VkZRKQGe9Nv46ZnWUzcF1xV01dcvEg/1wXUR61OmmlSfyeyO7EvjLxQ=="],
"@eslint/config-helpers": ["@eslint/config-helpers@0.3.0", "", {}, "sha512-ViuymvFmcJi04qdZeDc2whTHryouGcDlaxPqarTD0ZE10ISpxGUVZGZDx4w01upyIynL3iu6IXH2bS1NhclQMw=="],
"@eslint/core": ["@eslint/core@0.15.1", "", { "dependencies": { "@types/json-schema": "^7.0.15" } }, "sha512-bkOp+iumZCCbt1K1CmWf0R9pM5yKpDv+ZXtvSyQpudrI9kuFLp+bM2WOPXImuD/ceQuaa8f5pj93Y7zyECIGNA=="],
"@eslint/eslintrc": ["@eslint/eslintrc@3.3.1", "", { "dependencies": { "ajv": "^6.12.4", "debug": "^4.3.2", "espree": "^10.0.1", "globals": "^14.0.0", "ignore": "^5.2.0", "import-fresh": "^3.2.1", "js-yaml": "^4.1.0", "minimatch": "^3.1.2", "strip-json-comments": "^3.1.1" } }, "sha512-gtF186CXhIl1p4pJNGZw8Yc6RlshoePRvE0X91oPGb3vZ8pM3qOS9W9NGPat9LziaBV7XrJWGylNQXkGcnM3IQ=="],
"@eslint/js": ["@eslint/js@9.32.0", "", {}, "sha512-BBpRFZK3eX6uMLKz8WxFOBIFFcGFJ/g8XuwjTHCqHROSIsopI+ddn/d5Cfh36+7+e5edVS8dbSHnBNhrLEX0zg=="],
"@eslint/object-schema": ["@eslint/object-schema@2.1.6", "", {}, "sha512-RBMg5FRL0I0gs51M/guSAj5/e14VQ4tpZnQNWwuDT66P14I43ItmPfIZRhO9fUVIPOAQXU47atlywZ/czoqFPA=="],
"@eslint/plugin-kit": ["@eslint/plugin-kit@0.3.4", "", { "dependencies": { "@eslint/core": "^0.15.1", "levn": "^0.4.1" } }, "sha512-Ul5l+lHEcw3L5+k8POx6r74mxEYKG5kOb6Xpy2gCRW6zweT6TEhAf8vhxGgjhqrd/VO/Dirhsb+1hNpD1ue9hw=="],
"@floating-ui/core": ["@floating-ui/core@1.7.2", "", { "dependencies": { "@floating-ui/utils": "^0.2.10" } }, "sha512-wNB5ooIKHQc+Kui96jE/n69rHFWAVoxn5CAzL1Xdd8FG03cgY3MLO+GF9U3W737fYDSgPWA6MReKhBQBop6Pcw=="],
"@floating-ui/dom": ["@floating-ui/dom@1.7.2", "", { "dependencies": { "@floating-ui/core": "^1.7.2", "@floating-ui/utils": "^0.2.10" } }, "sha512-7cfaOQuCS27HD7DX+6ib2OrnW+b4ZBwDNnCcT0uTyidcmyWb03FnQqJybDBoCnpdxwBSfA94UAYlRCt7mV+TbA=="],
"@floating-ui/react-dom": ["@floating-ui/react-dom@2.1.4", "", { "dependencies": { "@floating-ui/dom": "^1.7.2" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-JbbpPhp38UmXDDAu60RJmbeme37Jbgsm7NrHGgzYYFKmblzRUh6Pa641dII6LsjwF4XlScDrde2UAzDo/b9KPw=="],
"@floating-ui/utils": ["@floating-ui/utils@0.2.10", "", {}, "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ=="],
"@hookform/resolvers": ["@hookform/resolvers@3.10.0", "", { "peerDependencies": { "react-hook-form": "^7.0.0" } }, "sha512-79Dv+3mDF7i+2ajj7SkypSKHhl1cbln1OGavqrsF7p6mbUv11xpqpacPsGDCTRvCSjEEIez2ef1NveSVL3b0Ag=="],
"@humanfs/core": ["@humanfs/core@0.19.1", "", {}, "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA=="],
"@humanfs/node": ["@humanfs/node@0.16.6", "", { "dependencies": { "@humanfs/core": "^0.19.1", "@humanwhocodes/retry": "^0.3.0" } }, "sha512-YuI2ZHQL78Q5HbhDiBA1X4LmYdXCKCMQIfw0pw7piHJwyREFebJUvrQN4cMssyES6x+vfUbx1CIpaQUKYdQZOw=="],
"@humanwhocodes/module-importer": ["@humanwhocodes/module-importer@1.0.1", "", {}, "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA=="],
"@humanwhocodes/retry": ["@humanwhocodes/retry@0.4.3", "", {}, "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ=="],
"@isaacs/cliui": ["@isaacs/cliui@8.0.2", "", { "dependencies": { "string-width": "^5.1.2", "string-width-cjs": "npm:string-width@^4.2.0", "strip-ansi": "^7.0.1", "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", "wrap-ansi": "^8.1.0", "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" } }, "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA=="],
"@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.5", "", { "dependencies": { "@jridgewell/set-array": "^1.2.1", "@jridgewell/sourcemap-codec": "^1.4.10", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-IzL8ZoEDIBRWEzlCcRhOaCupYyN5gdIK+Q6fbFdPDg6HqX6jpkItn7DFIpW9LQzXG6Df9sA7+OKnq0qlz/GaQg=="],
"@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="],
"@jridgewell/set-array": ["@jridgewell/set-array@1.2.1", "", {}, "sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A=="],
"@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="],
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.25", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ=="],
"@nodelib/fs.scandir": ["@nodelib/fs.scandir@2.1.5", "", { "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" } }, "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g=="],
"@nodelib/fs.stat": ["@nodelib/fs.stat@2.0.5", "", {}, "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A=="],
"@nodelib/fs.walk": ["@nodelib/fs.walk@1.2.8", "", { "dependencies": { "@nodelib/fs.scandir": "2.1.5", "fastq": "^1.6.0" } }, "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg=="],
"@pkgjs/parseargs": ["@pkgjs/parseargs@0.11.0", "", {}, "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg=="],
"@radix-ui/number": ["@radix-ui/number@1.1.1", "", {}, "sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g=="],
"@radix-ui/primitive": ["@radix-ui/primitive@1.1.2", "", {}, "sha512-XnbHrrprsNqZKQhStrSwgRUQzoCI1glLzdw79xiZPoofhGICeZRSQ3dIxAKH1gb3OHfNf4d6f+vAv3kil2eggA=="],
"@radix-ui/react-accordion": ["@radix-ui/react-accordion@1.2.11", "", { "dependencies": { "@radix-ui/primitive": "1.1.2", "@radix-ui/react-collapsible": "1.1.11", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-l3W5D54emV2ues7jjeG1xcyN7S3jnK3zE2zHqgn0CmMsy9lNJwmgcrmaxS+7ipw15FAivzKNzH3d5EcGoFKw0A=="],
"@radix-ui/react-alert-dialog": ["@radix-ui/react-alert-dialog@1.1.14", "", { "dependencies": { "@radix-ui/primitive": "1.1.2", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dialog": "1.1.14", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-IOZfZ3nPvN6lXpJTBCunFQPRSvK8MDgSc1FB85xnIpUKOw9en0dJj8JmCAxV7BiZdtYlUpmrQjoTFkVYtdoWzQ=="],
"@radix-ui/react-arrow": ["@radix-ui/react-arrow@1.1.7", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w=="],
"@radix-ui/react-aspect-ratio": ["@radix-ui/react-aspect-ratio@1.1.7", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-Yq6lvO9HQyPwev1onK1daHCHqXVLzPhSVjmsNjCa2Zcxy2f7uJD2itDtxknv6FzAKCwD1qQkeVDmX/cev13n/g=="],
"@radix-ui/react-avatar": ["@radix-ui/react-avatar@1.1.10", "", { "dependencies": { "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-is-hydrated": "0.1.0", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-V8piFfWapM5OmNCXTzVQY+E1rDa53zY+MQ4Y7356v4fFz6vqCyUtIz2rUD44ZEdwg78/jKmMJHj07+C/Z/rcog=="],
"@radix-ui/react-checkbox": ["@radix-ui/react-checkbox@1.3.2", "", { "dependencies": { "@radix-ui/primitive": "1.1.2", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-presence": "1.1.4", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-previous": "1.1.1", "@radix-ui/react-use-size": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-yd+dI56KZqawxKZrJ31eENUwqc1QSqg4OZ15rybGjF2ZNwMO+wCyHzAVLRp9qoYJf7kYy0YpZ2b0JCzJ42HZpA=="],
"@radix-ui/react-collapsible": ["@radix-ui/react-collapsible@1.1.11", "", { "dependencies": { "@radix-ui/primitive": "1.1.2", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-presence": "1.1.4", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-2qrRsVGSCYasSz1RFOorXwl0H7g7J1frQtgpQgYrt+MOidtPAINHn9CPovQXb83r8ahapdx3Tu0fa/pdFFSdPg=="],
"@radix-ui/react-collection": ["@radix-ui/react-collection@1.1.7", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw=="],
"@radix-ui/react-compose-refs": ["@radix-ui/react-compose-refs@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg=="],
"@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="],
"@radix-ui/react-context-menu": ["@radix-ui/react-context-menu@2.2.15", "", { "dependencies": { "@radix-ui/primitive": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-menu": "2.1.15", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-UsQUMjcYTsBjTSXw0P3GO0werEQvUY2plgRQuKoCTtkNr45q1DiL51j4m7gxhABzZ0BadoXNsIbg7F3KwiUBbw=="],
"@radix-ui/react-dialog": ["@radix-ui/react-dialog@1.1.14", "", { "dependencies": { "@radix-ui/primitive": "1.1.2", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.10", "@radix-ui/react-focus-guards": "1.1.2", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.4", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-controllable-state": "1.2.2", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-+CpweKjqpzTmwRwcYECQcNYbI8V9VSQt0SNFKeEBLgfucbsLssU6Ppq7wUdNXEGb573bMjFhVjKVll8rmV6zMw=="],
"@radix-ui/react-direction": ["@radix-ui/react-direction@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw=="],
"@radix-ui/react-dismissable-layer": ["@radix-ui/react-dismissable-layer@1.1.10", "", { "dependencies": { "@radix-ui/primitive": "1.1.2", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-escape-keydown": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-IM1zzRV4W3HtVgftdQiiOmA0AdJlCtMLe00FXaHwgt3rAnNsIyDqshvkIW3hj/iu5hu8ERP7KIYki6NkqDxAwQ=="],
"@radix-ui/react-dropdown-menu": ["@radix-ui/react-dropdown-menu@2.1.15", "", { "dependencies": { "@radix-ui/primitive": "1.1.2", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-menu": "2.1.15", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-mIBnOjgwo9AH3FyKaSWoSu/dYj6VdhJ7frEPiGTeXCdUFHjl9h3mFh2wwhEtINOmYXWhdpf1rY2minFsmaNgVQ=="],
"@radix-ui/react-focus-guards": ["@radix-ui/react-focus-guards@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-fyjAACV62oPV925xFCrH8DR5xWhg9KYtJT4s3u54jxp+L/hbpTY2kIeEFFbFe+a/HCE94zGQMZLIpVTPVZDhaA=="],
"@radix-ui/react-focus-scope": ["@radix-ui/react-focus-scope@1.1.7", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw=="],
"@radix-ui/react-hover-card": ["@radix-ui/react-hover-card@1.1.14", "", { "dependencies": { "@radix-ui/primitive": "1.1.2", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.10", "@radix-ui/react-popper": "1.2.7", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.4", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-CPYZ24Mhirm+g6D8jArmLzjYu4Eyg3TTUHswR26QgzXBHBe64BO/RHOJKzmF/Dxb4y4f9PKyJdwm/O/AhNkb+Q=="],
"@radix-ui/react-id": ["@radix-ui/react-id@1.1.1", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg=="],
"@radix-ui/react-label": ["@radix-ui/react-label@2.1.7", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-YT1GqPSL8kJn20djelMX7/cTRp/Y9w5IZHvfxQTVHrOqa2yMl7i/UfMqKRU5V7mEyKTrUVgJXhNQPVCG8PBLoQ=="],
"@radix-ui/react-menu": ["@radix-ui/react-menu@2.1.15", "", { "dependencies": { "@radix-ui/primitive": "1.1.2", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-dismissable-layer": "1.1.10", "@radix-ui/react-focus-guards": "1.1.2", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-popper": "1.2.7", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.4", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-roving-focus": "1.1.10", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-callback-ref": "1.1.1", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-tVlmA3Vb9n8SZSd+YSbuFR66l87Wiy4du+YE+0hzKQEANA+7cWKH1WgqcEX4pXqxUFQKrWQGHdvEfw00TjFiew=="],
"@radix-ui/react-menubar": ["@radix-ui/react-menubar@1.1.15", "", { "dependencies": { "@radix-ui/primitive": "1.1.2", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-menu": "2.1.15", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-roving-focus": "1.1.10", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-Z71C7LGD+YDYo3TV81paUs8f3Zbmkvg6VLRQpKYfzioOE6n7fOhA3ApK/V/2Odolxjoc4ENk8AYCjohCNayd5A=="],
"@radix-ui/react-navigation-menu": ["@radix-ui/react-navigation-menu@1.2.13", "", { "dependencies": { "@radix-ui/primitive": "1.1.2", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-dismissable-layer": "1.1.10", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-presence": "1.1.4", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-use-previous": "1.1.1", "@radix-ui/react-visually-hidden": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-WG8wWfDiJlSF5hELjwfjSGOXcBR/ZMhBFCGYe8vERpC39CQYZeq1PQ2kaYHdye3V95d06H89KGMsVCIE4LWo3g=="],
"@radix-ui/react-popover": ["@radix-ui/react-popover@1.1.14", "", { "dependencies": { "@radix-ui/primitive": "1.1.2", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.10", "@radix-ui/react-focus-guards": "1.1.2", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-popper": "1.2.7", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.4", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-controllable-state": "1.2.2", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-ODz16+1iIbGUfFEfKx2HTPKizg2MN39uIOV8MXeHnmdd3i/N9Wt7vU46wbHsqA0xoaQyXVcs0KIlBdOA2Y95bw=="],
"@radix-ui/react-popper": ["@radix-ui/react-popper@1.2.7", "", { "dependencies": { "@floating-ui/react-dom": "^2.0.0", "@radix-ui/react-arrow": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-use-rect": "1.1.1", "@radix-ui/react-use-size": "1.1.1", "@radix-ui/rect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-IUFAccz1JyKcf/RjB552PlWwxjeCJB8/4KxT7EhBHOJM+mN7LdW+B3kacJXILm32xawcMMjb2i0cIZpo+f9kiQ=="],
"@radix-ui/react-portal": ["@radix-ui/react-portal@1.1.9", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ=="],
"@radix-ui/react-presence": ["@radix-ui/react-presence@1.1.4", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-ueDqRbdc4/bkaQT3GIpLQssRlFgWaL/U2z/S31qRwwLWoxHLgry3SIfCwhxeQNbirEUXFa+lq3RL3oBYXtcmIA=="],
"@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="],
"@radix-ui/react-progress": ["@radix-ui/react-progress@1.1.7", "", { "dependencies": { "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-vPdg/tF6YC/ynuBIJlk1mm7Le0VgW6ub6J2UWnTQ7/D23KXcPI1qy+0vBkgKgd38RCMJavBXpB83HPNFMTb0Fg=="],
"@radix-ui/react-radio-group": ["@radix-ui/react-radio-group@1.3.7", "", { "dependencies": { "@radix-ui/primitive": "1.1.2", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-presence": "1.1.4", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-roving-focus": "1.1.10", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-previous": "1.1.1", "@radix-ui/react-use-size": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-9w5XhD0KPOrm92OTTE0SysH3sYzHsSTHNvZgUBo/VZ80VdYyB5RneDbc0dKpURS24IxkoFRu/hI0i4XyfFwY6g=="],
"@radix-ui/react-roving-focus": ["@radix-ui/react-roving-focus@1.1.10", "", { "dependencies": { "@radix-ui/primitive": "1.1.2", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-dT9aOXUen9JSsxnMPv/0VqySQf5eDQ6LCk5Sw28kamz8wSOW2bJdlX2Bg5VUIIcV+6XlHpWTIuTPCf/UNIyq8Q=="],
"@radix-ui/react-scroll-area": ["@radix-ui/react-scroll-area@1.2.9", "", { "dependencies": { "@radix-ui/number": "1.1.1", "@radix-ui/primitive": "1.1.2", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-presence": "1.1.4", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-YSjEfBXnhUELsO2VzjdtYYD4CfQjvao+lhhrX5XsHD7/cyUNzljF1FHEbgTPN7LH2MClfwRMIsYlqTYpKTTe2A=="],
"@radix-ui/react-select": ["@radix-ui/react-select@2.2.5", "", { "dependencies": { "@radix-ui/number": "1.1.1", "@radix-ui/primitive": "1.1.2", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-dismissable-layer": "1.1.10", "@radix-ui/react-focus-guards": "1.1.2", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-popper": "1.2.7", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-use-previous": "1.1.1", "@radix-ui/react-visually-hidden": "1.2.3", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-HnMTdXEVuuyzx63ME0ut4+sEMYW6oouHWNGUZc7ddvUWIcfCva/AMoqEW/3wnEllriMWBa0RHspCYnfCWJQYmA=="],
"@radix-ui/react-separator": ["@radix-ui/react-separator@1.1.7", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-0HEb8R9E8A+jZjvmFCy/J4xhbXy3TV+9XSnGJ3KvTtjlIUy/YQ/p6UYZvi7YbeoeXdyU9+Y3scizK6hkY37baA=="],
"@radix-ui/react-slider": ["@radix-ui/react-slider@1.3.5", "", { "dependencies": { "@radix-ui/number": "1.1.1", "@radix-ui/primitive": "1.1.2", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-use-previous": "1.1.1", "@radix-ui/react-use-size": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-rkfe2pU2NBAYfGaxa3Mqosi7VZEWX5CxKaanRv0vZd4Zhl9fvQrg0VM93dv3xGLGfrHuoTRF3JXH8nb9g+B3fw=="],
"@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
"@radix-ui/react-switch": ["@radix-ui/react-switch@1.2.5", "", { "dependencies": { "@radix-ui/primitive": "1.1.2", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-previous": "1.1.1", "@radix-ui/react-use-size": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-5ijLkak6ZMylXsaImpZ8u4Rlf5grRmoc0p0QeX9VJtlrM4f5m3nCTX8tWga/zOA8PZYIR/t0p2Mnvd7InrJ6yQ=="],
"@radix-ui/react-tabs": ["@radix-ui/react-tabs@1.1.12", "", { "dependencies": { "@radix-ui/primitive": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-presence": "1.1.4", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-roving-focus": "1.1.10", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-GTVAlRVrQrSw3cEARM0nAx73ixrWDPNZAruETn3oHCNP6SbZ/hNxdxp+u7VkIEv3/sFoLq1PfcHrl7Pnp0CDpw=="],
"@radix-ui/react-toast": ["@radix-ui/react-toast@1.2.14", "", { "dependencies": { "@radix-ui/primitive": "1.1.2", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.10", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.4", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-visually-hidden": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-nAP5FBxBJGQ/YfUB+r+O6USFVkWq3gAInkxyEnmvEV5jtSbfDhfa4hwX8CraCnbjMLsE7XSf/K75l9xXY7joWg=="],
"@radix-ui/react-toggle": ["@radix-ui/react-toggle@1.1.9", "", { "dependencies": { "@radix-ui/primitive": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-ZoFkBBz9zv9GWer7wIjvdRxmh2wyc2oKWw6C6CseWd6/yq1DK/l5lJ+wnsmFwJZbBYqr02mrf8A2q/CVCuM3ZA=="],
"@radix-ui/react-toggle-group": ["@radix-ui/react-toggle-group@1.1.10", "", { "dependencies": { "@radix-ui/primitive": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-roving-focus": "1.1.10", "@radix-ui/react-toggle": "1.1.9", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-kiU694Km3WFLTC75DdqgM/3Jauf3rD9wxeS9XtyWFKsBUeZA337lC+6uUazT7I1DhanZ5gyD5Stf8uf2dbQxOQ=="],
"@radix-ui/react-tooltip": ["@radix-ui/react-tooltip@1.2.7", "", { "dependencies": { "@radix-ui/primitive": "1.1.2", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.10", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-popper": "1.2.7", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.4", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-visually-hidden": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-Ap+fNYwKTYJ9pzqW+Xe2HtMRbQ/EeWkj2qykZ6SuEV4iS/o1bZI5ssJbk4D2r8XuDuOBVz/tIx2JObtuqU+5Zw=="],
"@radix-ui/react-use-callback-ref": ["@radix-ui/react-use-callback-ref@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg=="],
"@radix-ui/react-use-controllable-state": ["@radix-ui/react-use-controllable-state@1.2.2", "", { "dependencies": { "@radix-ui/react-use-effect-event": "0.0.2", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg=="],
"@radix-ui/react-use-effect-event": ["@radix-ui/react-use-effect-event@0.0.2", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA=="],
"@radix-ui/react-use-escape-keydown": ["@radix-ui/react-use-escape-keydown@1.1.1", "", { "dependencies": { "@radix-ui/react-use-callback-ref": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g=="],
"@radix-ui/react-use-is-hydrated": ["@radix-ui/react-use-is-hydrated@0.1.0", "", { "dependencies": { "use-sync-external-store": "^1.5.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-U+UORVEq+cTnRIaostJv9AGdV3G6Y+zbVd+12e18jQ5A3c0xL03IhnHuiU4UV69wolOQp5GfR58NW/EgdQhwOA=="],
"@radix-ui/react-use-layout-effect": ["@radix-ui/react-use-layout-effect@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ=="],
"@radix-ui/react-use-previous": ["@radix-ui/react-use-previous@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-2dHfToCj/pzca2Ck724OZ5L0EVrr3eHRNsG/b3xQJLA2hZpVCS99bLAX+hm1IHXDEnzU6by5z/5MIY794/a8NQ=="],
"@radix-ui/react-use-rect": ["@radix-ui/react-use-rect@1.1.1", "", { "dependencies": { "@radix-ui/rect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-QTYuDesS0VtuHNNvMh+CjlKJ4LJickCMUAqjlE3+j8w+RlRpwyX3apEQKGFzbZGdo7XNG1tXa+bQqIE7HIXT2w=="],
"@radix-ui/react-use-size": ["@radix-ui/react-use-size@1.1.1", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-ewrXRDTAqAXlkl6t/fkXWNAhFX9I+CkKlw6zjEwk86RSPKwZr3xpBRso655aqYafwtnbpHLj6toFzmd6xdVptQ=="],
"@radix-ui/react-visually-hidden": ["@radix-ui/react-visually-hidden@1.2.3", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-pzJq12tEaaIhqjbzpCuv/OypJY/BPavOofm+dbab+MHLajy277+1lLm6JFcGgF5eskJ6mquGirhXY2GD/8u8Ug=="],
"@radix-ui/rect": ["@radix-ui/rect@1.1.1", "", {}, "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw=="],
"@remix-run/router": ["@remix-run/router@1.23.0", "", {}, "sha512-O3rHJzAQKamUz1fvE0Qaw0xSFqsA/yafi2iqeE0pvdFtCO1viYx8QL6f3Ln/aCCTLxs68SLf0KPM9eSeM8yBnA=="],
"@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-beta.27", "", {}, "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA=="],
"@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.24.0", "", { "os": "android", "cpu": "arm" }, "sha512-Q6HJd7Y6xdB48x8ZNVDOqsbh2uByBhgK8PiQgPhwkIw/HC/YX5Ghq2mQY5sRMZWHb3VsFkWooUVOZHKr7DmDIA=="],
"@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.24.0", "", { "os": "android", "cpu": "arm64" }, "sha512-ijLnS1qFId8xhKjT81uBHuuJp2lU4x2yxa4ctFPtG+MqEE6+C5f/+X/bStmxapgmwLwiL3ih122xv8kVARNAZA=="],
"@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.24.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-bIv+X9xeSs1XCk6DVvkO+S/z8/2AMt/2lMqdQbMrmVpgFvXlmde9mLcbQpztXm1tajC3raFDqegsH18HQPMYtA=="],
"@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.24.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-X6/nOwoFN7RT2svEQWUsW/5C/fYMBe4fnLK9DQk4SX4mgVBiTA9h64kjUYPvGQ0F/9xwJ5U5UfTbl6BEjaQdBQ=="],
"@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.24.0", "", { "os": "linux", "cpu": "arm" }, "sha512-0KXvIJQMOImLCVCz9uvvdPgfyWo93aHHp8ui3FrtOP57svqrF/roSSR5pjqL2hcMp0ljeGlU4q9o/rQaAQ3AYA=="],
"@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.24.0", "", { "os": "linux", "cpu": "arm" }, "sha512-it2BW6kKFVh8xk/BnHfakEeoLPv8STIISekpoF+nBgWM4d55CZKc7T4Dx1pEbTnYm/xEKMgy1MNtYuoA8RFIWw=="],
"@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.24.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-i0xTLXjqap2eRfulFVlSnM5dEbTVque/3Pi4g2y7cxrs7+a9De42z4XxKLYJ7+OhE3IgxvfQM7vQc43bwTgPwA=="],
"@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.24.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-9E6MKUJhDuDh604Qco5yP/3qn3y7SLXYuiC0Rpr89aMScS2UAmK1wHP2b7KAa1nSjWJc/f/Lc0Wl1L47qjiyQw=="],
"@rollup/rollup-linux-powerpc64le-gnu": ["@rollup/rollup-linux-powerpc64le-gnu@4.24.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-2XFFPJ2XMEiF5Zi2EBf4h73oR1V/lycirxZxHZNc93SqDN/IWhYYSYj8I9381ikUFXZrz2v7r2tOVk2NBwxrWw=="],
"@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.24.0", "", { "os": "linux", "cpu": "none" }, "sha512-M3Dg4hlwuntUCdzU7KjYqbbd+BLq3JMAOhCKdBE3TcMGMZbKkDdJ5ivNdehOssMCIokNHFOsv7DO4rlEOfyKpg=="],
"@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.24.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-mjBaoo4ocxJppTorZVKWFpy1bfFj9FeCMJqzlMQGjpNPY9JwQi7OuS1axzNIk0nMX6jSgy6ZURDZ2w0QW6D56g=="],
"@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.24.0", "", { "os": "linux", "cpu": "x64" }, "sha512-ZXFk7M72R0YYFN5q13niV0B7G8/5dcQ9JDp8keJSfr3GoZeXEoMHP/HlvqROA3OMbMdfr19IjCeNAnPUG93b6A=="],
"@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.24.0", "", { "os": "linux", "cpu": "x64" }, "sha512-w1i+L7kAXZNdYl+vFvzSZy8Y1arS7vMgIy8wusXJzRrPyof5LAb02KGr1PD2EkRcl73kHulIID0M501lN+vobQ=="],
"@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.24.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-VXBrnPWgBpVDCVY6XF3LEW0pOU51KbaHhccHw6AS6vBWIC60eqsH19DAeeObl+g8nKAz04QFdl/Cefta0xQtUQ=="],
"@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.24.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-xrNcGDU0OxVcPTH/8n/ShH4UevZxKIO6HJFK0e15XItZP2UcaiLFd5kiX7hJnqCbSztUF8Qot+JWBC/QXRPYWQ=="],
"@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.24.0", "", { "os": "win32", "cpu": "x64" }, "sha512-fbMkAF7fufku0N2dE5TBXcNlg0pt0cJue4xBRE2Qc5Vqikxr4VCgKj/ht6SMdFcOacVA9rqF70APJ8RN/4vMJw=="],
"@stripe/react-stripe-js": ["@stripe/react-stripe-js@6.2.0", "https://europe-west1-npm.pkg.dev/lovable-core-prod/sandbox-npm-cache/@stripe/react-stripe-js/-/react-stripe-js-6.2.0.tgz", { "dependencies": { "prop-types": "^15.7.2" }, "peerDependencies": { "@stripe/stripe-js": ">=9.2.0 <10.0.0", "react": ">=16.8.0 <20.0.0", "react-dom": ">=16.8.0 <20.0.0" } }, "sha512-GSCErjljZEQv9LaxP30xGOwstcMyyUzb5JyihXwvjOU95yrfhbiPG4K2KkwxYxn+WY0/AyHsRhPPoGRw7urBzg=="],
"@stripe/stripe-js": ["@stripe/stripe-js@9.2.0", "https://europe-west1-npm.pkg.dev/lovable-core-prod/sandbox-npm-cache/@stripe/stripe-js/-/stripe-js-9.2.0.tgz", {}, "sha512-YSzLC0t6VS9MDdPTynSMqU8IxrItFUjkDORALFT6sSMR/XZ5Vgm3RDp/Gk7z727MC4A9s4MFVel0gF0c7+kdrg=="],
"@supabase/auth-js": ["@supabase/auth-js@2.93.1", "", { "dependencies": { "tslib": "2.8.1" } }, "sha512-pC0Ek4xk4z6q7A/3+UuZ/eYgfFUUQTg3DhapzrAgJnFGDJDFDyGCj6v9nIz8+3jfLqSZ3QKGe6AoEodYjShghg=="],
"@supabase/functions-js": ["@supabase/functions-js@2.93.1", "", { "dependencies": { "tslib": "2.8.1" } }, "sha512-Ott2IcIXHGupaC0nX9WNEiJAX4OdlGRu9upkkURaQHbaLdz9JuCcHxlwTERgtgjMpikbIWHfMM1M9QTQFYABiA=="],
"@supabase/postgrest-js": ["@supabase/postgrest-js@2.93.1", "", { "dependencies": { "tslib": "2.8.1" } }, "sha512-uRKKQJBDnfi6XFNFPNMh9+u3HT2PCgp065PcMPmG7e0xGuqvLtN89QxO2/SZcGbw2y1+mNBz0yUs5KmyNqF2fA=="],
"@supabase/realtime-js": ["@supabase/realtime-js@2.93.1", "", { "dependencies": { "@types/phoenix": "^1.6.6", "@types/ws": "^8.18.1", "tslib": "2.8.1", "ws": "^8.18.2" } }, "sha512-2WaP/KVHPlQDjWM6qe4wOZz6zSRGaXw1lfXf4thbfvk3C3zPPKqXRyspyYnk3IhphyxSsJ2hQ/cXNOz48008tg=="],
"@supabase/storage-js": ["@supabase/storage-js@2.93.1", "", { "dependencies": { "iceberg-js": "^0.8.1", "tslib": "2.8.1" } }, "sha512-3KVwd4S1i1BVPL6KIywe5rnruNQXSkLyvrdiJmwnqwbCcDujQumARdGWBPesqCjOPKEU2M9ORWKAsn+2iLzquA=="],
"@supabase/supabase-js": ["@supabase/supabase-js@2.93.1", "", { "dependencies": { "@supabase/auth-js": "2.93.1", "@supabase/functions-js": "2.93.1", "@supabase/postgrest-js": "2.93.1", "@supabase/realtime-js": "2.93.1", "@supabase/storage-js": "2.93.1" } }, "sha512-FJTgS5s0xEgRQ3u7gMuzGObwf3jA4O5Ki/DgCDXx94w1pihLM4/WG3XFa4BaCJYfuzLxLcv6zPPA5tDvBUjAUg=="],
"@swc/core": ["@swc/core@1.13.2", "", { "dependencies": { "@swc/counter": "^0.1.3", "@swc/types": "^0.1.23" }, "optionalDependencies": { "@swc/core-darwin-arm64": "1.13.2", "@swc/core-darwin-x64": "1.13.2", "@swc/core-linux-arm-gnueabihf": "1.13.2", "@swc/core-linux-arm64-gnu": "1.13.2", "@swc/core-linux-arm64-musl": "1.13.2", "@swc/core-linux-x64-gnu": "1.13.2", "@swc/core-linux-x64-musl": "1.13.2", "@swc/core-win32-arm64-msvc": "1.13.2", "@swc/core-win32-ia32-msvc": "1.13.2", "@swc/core-win32-x64-msvc": "1.13.2" }, "peerDependencies": { "@swc/helpers": ">=0.5.17" }, "optionalPeers": ["@swc/helpers"] }, "sha512-YWqn+0IKXDhqVLKoac4v2tV6hJqB/wOh8/Br8zjqeqBkKa77Qb0Kw2i7LOFzjFNZbZaPH6AlMGlBwNrxaauaAg=="],
"@swc/core-darwin-arm64": ["@swc/core-darwin-arm64@1.13.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-44p7ivuLSGFJ15Vly4ivLJjg3ARo4879LtEBAabcHhSZygpmkP8eyjyWxrH3OxkY1eRZSIJe8yRZPFw4kPXFPw=="],
"@swc/core-darwin-x64": ["@swc/core-darwin-x64@1.13.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-Lb9EZi7X2XDAVmuUlBm2UvVAgSCbD3qKqDCxSI4jEOddzVOpNCnyZ/xEampdngUIyDDhhJLYU9duC+Mcsv5Y+A=="],
"@swc/core-linux-arm-gnueabihf": ["@swc/core-linux-arm-gnueabihf@1.13.2", "", { "os": "linux", "cpu": "arm" }, "sha512-9TDe/92ee1x57x+0OqL1huG4BeljVx0nWW4QOOxp8CCK67Rpc/HHl2wciJ0Kl9Dxf2NvpNtkPvqj9+BUmM9WVA=="],
"@swc/core-linux-arm64-gnu": ["@swc/core-linux-arm64-gnu@1.13.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-KJUSl56DBk7AWMAIEcU83zl5mg3vlQYhLELhjwRFkGFMvghQvdqQ3zFOYa4TexKA7noBZa3C8fb24rI5sw9Exg=="],
"@swc/core-linux-arm64-musl": ["@swc/core-linux-arm64-musl@1.13.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-teU27iG1oyWpNh9CzcGQ48ClDRt/RCem7mYO7ehd2FY102UeTws2+OzLESS1TS1tEZipq/5xwx3FzbVgiolCiQ=="],
"@swc/core-linux-x64-gnu": ["@swc/core-linux-x64-gnu@1.13.2", "", { "os": "linux", "cpu": "x64" }, "sha512-dRPsyPyqpLD0HMRCRpYALIh4kdOir8pPg4AhNQZLehKowigRd30RcLXGNVZcc31Ua8CiPI4QSgjOIxK+EQe4LQ=="],
"@swc/core-linux-x64-musl": ["@swc/core-linux-x64-musl@1.13.2", "", { "os": "linux", "cpu": "x64" }, "sha512-CCxETW+KkYEQDqz1SYC15YIWYheqFC+PJVOW76Maa/8yu8Biw+HTAcblKf2isrlUtK8RvrQN94v3UXkC2NzCEw=="],
"@swc/core-win32-arm64-msvc": ["@swc/core-win32-arm64-msvc@1.13.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-Wv/QTA6PjyRLlmKcN6AmSI4jwSMRl0VTLGs57PHTqYRwwfwd7y4s2fIPJVBNbAlXd795dOEP6d/bGSQSyhOX3A=="],
"@swc/core-win32-ia32-msvc": ["@swc/core-win32-ia32-msvc@1.13.2", "", { "os": "win32", "cpu": "ia32" }, "sha512-PuCdtNynEkUNbUXX/wsyUC+t4mamIU5y00lT5vJcAvco3/r16Iaxl5UCzhXYaWZSNVZMzPp9qN8NlSL8M5pPxw=="],
"@swc/core-win32-x64-msvc": ["@swc/core-win32-x64-msvc@1.13.2", "", { "os": "win32", "cpu": "x64" }, "sha512-qlmMkFZJus8cYuBURx1a3YAG2G7IW44i+FEYV5/32ylKkzGNAr9tDJSA53XNnNXkAB5EXSPsOz7bn5C3JlEtdQ=="],
"@swc/counter": ["@swc/counter@0.1.3", "", {}, "sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ=="],
"@swc/types": ["@swc/types@0.1.23", "", { "dependencies": { "@swc/counter": "^0.1.3" } }, "sha512-u1iIVZV9Q0jxY+yM2vw/hZGDNudsN85bBpTqzAQ9rzkxW9D+e3aEM4Han+ow518gSewkXgjmEK0BD79ZcNVgPw=="],
"@tailwindcss/typography": ["@tailwindcss/typography@0.5.16", "", { "dependencies": { "lodash.castarray": "^4.4.0", "lodash.isplainobject": "^4.0.6", "lodash.merge": "^4.6.2", "postcss-selector-parser": "6.0.10" }, "peerDependencies": { "tailwindcss": ">=3.0.0 || insiders || >=4.0.0-alpha.20 || >=4.0.0-beta.1" } }, "sha512-0wDLwCVF5V3x3b1SGXPCDcdsbDHMBe+lkFzBRaHeLvNi+nrrnZ1lA18u+OTWO8iSWU2GxUOCvlXtDuqftc1oiA=="],
"@tanstack/query-core": ["@tanstack/query-core@5.83.0", "", {}, "sha512-0M8dA+amXUkyz5cVUm/B+zSk3xkQAcuXuz5/Q/LveT4ots2rBpPTZOzd7yJa2Utsf8D2Upl5KyjhHRY+9lB/XA=="],
"@tanstack/react-query": ["@tanstack/react-query@5.83.0", "", { "dependencies": { "@tanstack/query-core": "5.83.0" }, "peerDependencies": { "react": "^18 || ^19" } }, "sha512-/XGYhZ3foc5H0VM2jLSD/NyBRIOK4q9kfeml4+0x2DlL6xVuAcVEW+hTlTapAmejObg0i3eNqhkr2dT+eciwoQ=="],
"@testing-library/jest-dom": ["@testing-library/jest-dom@6.9.1", "", { "dependencies": { "@adobe/css-tools": "^4.4.0", "aria-query": "^5.0.0", "css.escape": "^1.5.1", "dom-accessibility-api": "^0.6.3", "picocolors": "^1.1.1", "redent": "^3.0.0" } }, "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA=="],
"@testing-library/react": ["@testing-library/react@16.3.2", "", { "dependencies": { "@babel/runtime": "^7.12.5" }, "peerDependencies": { "@types/react": "^18.0.0 || ^19.0.0", "@types/react-dom": "^18.0.0 || ^19.0.0", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" } }, "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g=="],
"@tootallnate/once": ["@tootallnate/once@2.0.0", "", {}, "sha512-XCuKFP5PS55gnMVu3dty8KPatLqUoy/ZYzDzAGCQ8JNFCkLXzmI7vNHCR+XpbZaMWQK/vQubr7PkYq8g470J/A=="],
"@types/chai": ["@types/chai@5.2.3", "", { "dependencies": { "@types/deep-eql": "*", "assertion-error": "^2.0.1" } }, "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA=="],
"@types/d3-array": ["@types/d3-array@3.2.1", "", {}, "sha512-Y2Jn2idRrLzUfAKV2LyRImR+y4oa2AntrgID95SHJxuMUrkNXmanDSed71sRNZysveJVt1hLLemQZIady0FpEg=="],
"@types/d3-color": ["@types/d3-color@3.1.3", "", {}, "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A=="],
"@types/d3-ease": ["@types/d3-ease@3.0.2", "", {}, "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA=="],
"@types/d3-interpolate": ["@types/d3-interpolate@3.0.4", "", { "dependencies": { "@types/d3-color": "*" } }, "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA=="],
"@types/d3-path": ["@types/d3-path@3.1.0", "", {}, "sha512-P2dlU/q51fkOc/Gfl3Ul9kicV7l+ra934qBFXCFhrZMOL6du1TM0pm1ThYvENukyOn5h9v+yMJ9Fn5JK4QozrQ=="],
"@types/d3-scale": ["@types/d3-scale@4.0.8", "", { "dependencies": { "@types/d3-time": "*" } }, "sha512-gkK1VVTr5iNiYJ7vWDI+yUFFlszhNMtVeneJ6lUTKPjprsvLLI9/tgEGiXJOnlINJA8FyA88gfnQsHbybVZrYQ=="],
"@types/d3-shape": ["@types/d3-shape@3.1.6", "", { "dependencies": { "@types/d3-path": "*" } }, "sha512-5KKk5aKGu2I+O6SONMYSNflgiP0WfZIQvVUMan50wHsLG1G94JlxEVnCpQARfTtzytuY0p/9PXXZb3I7giofIA=="],
"@types/d3-time": ["@types/d3-time@3.0.3", "", {}, "sha512-2p6olUZ4w3s+07q3Tm2dbiMZy5pCDfYwtLXXHUnVzXgQlZ/OyPtUz6OL382BkOuGlLXqfT+wqv8Fw2v8/0geBw=="],
"@types/d3-timer": ["@types/d3-timer@3.0.2", "", {}, "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw=="],
"@types/deep-eql": ["@types/deep-eql@4.0.2", "", {}, "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw=="],
"@types/estree": ["@types/estree@1.0.6", "", {}, "sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw=="],
"@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="],
"@types/node": ["@types/node@22.16.5", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-bJFoMATwIGaxxx8VJPeM8TonI8t579oRvgAuT8zFugJsJZgzqv0Fu8Mhp68iecjzG7cnN3mO2dJQ5uUM2EFrgQ=="],
"@types/phoenix": ["@types/phoenix@1.6.7", "", {}, "sha512-oN9ive//QSBkf19rfDv45M7eZPi0eEXylht2OLEXicu5b4KoQ1OzXIw+xDSGWxSxe1JmepRR/ZH283vsu518/Q=="],
"@types/prop-types": ["@types/prop-types@15.7.13", "", {}, "sha512-hCZTSvwbzWGvhqxp/RqVqwU999pBf2vp7hzIjiYOsl8wqOmUxkQ6ddw1cV3l8811+kdUFus/q4d1Y3E3SyEifA=="],
"@types/react": ["@types/react@18.3.23", "", { "dependencies": { "@types/prop-types": "*", "csstype": "^3.0.2" } }, "sha512-/LDXMQh55EzZQ0uVAZmKKhfENivEvWz6E+EYzh+/MCjMhNsotd+ZHhBGIjFDTi6+fz0OhQQQLbTgdQIxxCsC0w=="],
"@types/react-dom": ["@types/react-dom@18.3.7", "", { "peerDependencies": { "@types/react": "^18.0.0" } }, "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ=="],
"@types/ws": ["@types/ws@8.18.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="],
"@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.38.0", "", { "dependencies": { "@eslint-community/regexpp": "^4.10.0", "@typescript-eslint/scope-manager": "8.38.0", "@typescript-eslint/type-utils": "8.38.0", "@typescript-eslint/utils": "8.38.0", "@typescript-eslint/visitor-keys": "8.38.0", "graphemer": "^1.4.0", "ignore": "^7.0.0", "natural-compare": "^1.4.0", "ts-api-utils": "^2.1.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.38.0", "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <5.9.0" } }, "sha512-CPoznzpuAnIOl4nhj4tRr4gIPj5AfKgkiJmGQDaq+fQnRJTYlcBjbX3wbciGmpoPf8DREufuPRe1tNMZnGdanA=="],
"@typescript-eslint/parser": ["@typescript-eslint/parser@8.38.0", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.38.0", "@typescript-eslint/types": "8.38.0", "@typescript-eslint/typescript-estree": "8.38.0", "@typescript-eslint/visitor-keys": "8.38.0", "debug": "^4.3.4" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <5.9.0" } }, "sha512-Zhy8HCvBUEfBECzIl1PKqF4p11+d0aUJS1GeUiuqK9WmOug8YCmC4h4bjyBvMyAMI9sbRczmrYL5lKg/YMbrcQ=="],
"@typescript-eslint/project-service": ["@typescript-eslint/project-service@8.38.0", "", { "dependencies": { "@typescript-eslint/tsconfig-utils": "^8.38.0", "@typescript-eslint/types": "^8.38.0", "debug": "^4.3.4" }, "peerDependencies": { "typescript": ">=4.8.4 <5.9.0" } }, "sha512-dbK7Jvqcb8c9QfH01YB6pORpqX1mn5gDZc9n63Ak/+jD67oWXn3Gs0M6vddAN+eDXBCS5EmNWzbSxsn9SzFWWg=="],
"@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.38.0", "", { "dependencies": { "@typescript-eslint/types": "8.38.0", "@typescript-eslint/visitor-keys": "8.38.0" } }, "sha512-WJw3AVlFFcdT9Ri1xs/lg8LwDqgekWXWhH3iAF+1ZM+QPd7oxQ6jvtW/JPwzAScxitILUIFs0/AnQ/UWHzbATQ=="],
"@typescript-eslint/tsconfig-utils": ["@typescript-eslint/tsconfig-utils@8.38.0", "", { "peerDependencies": { "typescript": ">=4.8.4 <5.9.0" } }, "sha512-Lum9RtSE3EroKk/bYns+sPOodqb2Fv50XOl/gMviMKNvanETUuUcC9ObRbzrJ4VSd2JalPqgSAavwrPiPvnAiQ=="],
"@typescript-eslint/type-utils": ["@typescript-eslint/type-utils@8.38.0", "", { "dependencies": { "@typescript-eslint/types": "8.38.0", "@typescript-eslint/typescript-estree": "8.38.0", "@typescript-eslint/utils": "8.38.0", "debug": "^4.3.4", "ts-api-utils": "^2.1.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <5.9.0" } }, "sha512-c7jAvGEZVf0ao2z+nnz8BUaHZD09Agbh+DY7qvBQqLiz8uJzRgVPj5YvOh8I8uEiH8oIUGIfHzMwUcGVco/SJg=="],
"@typescript-eslint/types": ["@typescript-eslint/types@8.38.0", "", {}, "sha512-wzkUfX3plUqij4YwWaJyqhiPE5UCRVlFpKn1oCRn2O1bJ592XxWJj8ROQ3JD5MYXLORW84063z3tZTb/cs4Tyw=="],
"@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@8.38.0", "", { "dependencies": { "@typescript-eslint/project-service": "8.38.0", "@typescript-eslint/tsconfig-utils": "8.38.0", "@typescript-eslint/types": "8.38.0", "@typescript-eslint/visitor-keys": "8.38.0", "debug": "^4.3.4", "fast-glob": "^3.3.2", "is-glob": "^4.0.3", "minimatch": "^9.0.4", "semver": "^7.6.0", "ts-api-utils": "^2.1.0" }, "peerDependencies": { "typescript": ">=4.8.4 <5.9.0" } }, "sha512-fooELKcAKzxux6fA6pxOflpNS0jc+nOQEEOipXFNjSlBS6fqrJOVY/whSn70SScHrcJ2LDsxWrneFoWYSVfqhQ=="],
"@typescript-eslint/utils": ["@typescript-eslint/utils@8.38.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.7.0", "@typescript-eslint/scope-manager": "8.38.0", "@typescript-eslint/types": "8.38.0", "@typescript-eslint/typescript-estree": "8.38.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <5.9.0" } }, "sha512-hHcMA86Hgt+ijJlrD8fX0j1j8w4C92zue/8LOPAFioIno+W0+L7KqE8QZKCcPGc/92Vs9x36w/4MPTJhqXdyvg=="],
"@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.38.0", "", { "dependencies": { "@typescript-eslint/types": "8.38.0", "eslint-visitor-keys": "^4.2.1" } }, "sha512-pWrTcoFNWuwHlA9CvlfSsGWs14JxfN1TH25zM5L7o0pRLhsoZkDnTsXfQRJBEWJoV5DL0jf+Z+sxiud+K0mq1g=="],
"@vitejs/plugin-react-swc": ["@vitejs/plugin-react-swc@3.11.0", "", { "dependencies": { "@rolldown/pluginutils": "1.0.0-beta.27", "@swc/core": "^1.12.11" }, "peerDependencies": { "vite": "^4 || ^5 || ^6 || ^7" } }, "sha512-YTJCGFdNMHCMfjODYtxRNVAYmTWQ1Lb8PulP/2/f/oEEtglw8oKxKIZmmRkyXrVrHfsKOaVkAc3NT9/dMutO5w=="],
"@vitest/expect": ["@vitest/expect@3.2.4", "", { "dependencies": { "@types/chai": "^5.2.2", "@vitest/spy": "3.2.4", "@vitest/utils": "3.2.4", "chai": "^5.2.0", "tinyrainbow": "^2.0.0" } }, "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig=="],
"@vitest/mocker": ["@vitest/mocker@3.2.4", "", { "dependencies": { "@vitest/spy": "3.2.4", "estree-walker": "^3.0.3", "magic-string": "^0.30.17" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" }, "optionalPeers": ["msw"] }, "sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ=="],
"@vitest/pretty-format": ["@vitest/pretty-format@3.2.4", "", { "dependencies": { "tinyrainbow": "^2.0.0" } }, "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA=="],
"@vitest/runner": ["@vitest/runner@3.2.4", "", { "dependencies": { "@vitest/utils": "3.2.4", "pathe": "^2.0.3", "strip-literal": "^3.0.0" } }, "sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ=="],
"@vitest/snapshot": ["@vitest/snapshot@3.2.4", "", { "dependencies": { "@vitest/pretty-format": "3.2.4", "magic-string": "^0.30.17", "pathe": "^2.0.3" } }, "sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ=="],
"@vitest/spy": ["@vitest/spy@3.2.4", "", { "dependencies": { "tinyspy": "^4.0.3" } }, "sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw=="],
"@vitest/utils": ["@vitest/utils@3.2.4", "", { "dependencies": { "@vitest/pretty-format": "3.2.4", "loupe": "^3.1.4", "tinyrainbow": "^2.0.0" } }, "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA=="],
"abab": ["abab@2.0.6", "", {}, "sha512-j2afSsaIENvHZN2B8GOpF566vZ5WVk5opAiMTvWgaQT8DkbOqsTfvNAvHoRGU2zzP8cPoqys+xHTRDWW8L+/BA=="],
"acorn": ["acorn@8.15.0", "", { "bin": "bin/acorn" }, "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg=="],
"acorn-globals": ["acorn-globals@7.0.1", "", { "dependencies": { "acorn": "^8.1.0", "acorn-walk": "^8.0.2" } }, "sha512-umOSDSDrfHbTNPuNpC2NSnnA3LUrqpevPb4T9jRx4MagXNS0rs+gwiTcAvqCRmsD6utzsrzNt+ebm00SNWiC3Q=="],
"acorn-jsx": ["acorn-jsx@5.3.2", "", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="],
"acorn-walk": ["acorn-walk@8.3.4", "", { "dependencies": { "acorn": "^8.11.0" } }, "sha512-ueEepnujpqee2o5aIYnvHU6C0A42MNdsIDeqy5BydrkuC5R1ZuUFnm27EeFJGoEHJQgn3uleRvmTXaJgfXbt4g=="],
"agent-base": ["agent-base@6.0.2", "", { "dependencies": { "debug": "4" } }, "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ=="],
"ajv": ["ajv@6.12.6", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g=="],
"ansi-regex": ["ansi-regex@6.1.0", "", {}, "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA=="],
"ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
"any-promise": ["any-promise@1.3.0", "", {}, "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A=="],
"anymatch": ["anymatch@3.1.3", "", { "dependencies": { "normalize-path": "^3.0.0", "picomatch": "^2.0.4" } }, "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw=="],
"arg": ["arg@5.0.2", "", {}, "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg=="],
"argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="],
"aria-hidden": ["aria-hidden@1.2.4", "", { "dependencies": { "tslib": "^2.0.0" } }, "sha512-y+CcFFwelSXpLZk/7fMB2mUbGtX9lKycf1MWJ7CaTIERyitVlyQx6C+sxcROU2BAJ24OiZyK+8wj2i8AlBoS3A=="],
"aria-query": ["aria-query@5.3.2", "", {}, "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw=="],
"assertion-error": ["assertion-error@2.0.1", "", {}, "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA=="],
"asynckit": ["asynckit@0.4.0", "", {}, "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="],
"autoprefixer": ["autoprefixer@10.4.21", "", { "dependencies": { "browserslist": "^4.24.4", "caniuse-lite": "^1.0.30001702", "fraction.js": "^4.3.7", "normalize-range": "^0.1.2", "picocolors": "^1.1.1", "postcss-value-parser": "^4.2.0" }, "peerDependencies": { "postcss": "^8.1.0" }, "bin": "bin/autoprefixer" }, "sha512-O+A6LWV5LDHSJD3LjHYoNi4VLsj/Whi7k6zG12xTYaU4cQ8oxQGckXNX8cRHK5yOZ/ppVHe0ZBXGzSV9jXdVbQ=="],
"balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
"binary-extensions": ["binary-extensions@2.3.0", "", {}, "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw=="],
"brace-expansion": ["brace-expansion@1.1.12", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg=="],
"braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="],
"browserslist": ["browserslist@4.25.1", "", { "dependencies": { "caniuse-lite": "^1.0.30001726", "electron-to-chromium": "^1.5.173", "node-releases": "^2.0.19", "update-browserslist-db": "^1.1.3" }, "bin": "cli.js" }, "sha512-KGj0KoOMXLpSNkkEI6Z6mShmQy0bc1I+T7K9N81k4WWMrfz+6fQ6es80B/YLAeRoKvjYE1YSHHOW1qe9xIVzHw=="],
"cac": ["cac@6.7.14", "", {}, "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ=="],
"call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="],
"callsites": ["callsites@3.1.0", "", {}, "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ=="],
"camelcase-css": ["camelcase-css@2.0.1", "", {}, "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA=="],
"caniuse-lite": ["caniuse-lite@1.0.30001727", "", {}, "sha512-pB68nIHmbN6L/4C6MH1DokyR3bYqFwjaSs/sWDHGj4CTcFtQUQMuJftVwWkXq7mNWOybD3KhUv3oWHoGxgP14Q=="],
"chai": ["chai@5.3.3", "", { "dependencies": { "assertion-error": "^2.0.1", "check-error": "^2.1.1", "deep-eql": "^5.0.1", "loupe": "^3.1.0", "pathval": "^2.0.0" } }, "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw=="],
"chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="],
"check-error": ["check-error@2.1.3", "", {}, "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA=="],
"chokidar": ["chokidar@3.6.0", "", { "dependencies": { "anymatch": "~3.1.2", "braces": "~3.0.2", "glob-parent": "~5.1.2", "is-binary-path": "~2.1.0", "is-glob": "~4.0.1", "normalize-path": "~3.0.0", "readdirp": "~3.6.0" }, "optionalDependencies": { "fsevents": "~2.3.2" } }, "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw=="],
"class-variance-authority": ["class-variance-authority@0.7.1", "", { "dependencies": { "clsx": "^2.1.1" } }, "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg=="],
"clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="],
"cmdk": ["cmdk@1.1.1", "", { "dependencies": { "@radix-ui/react-compose-refs": "^1.1.1", "@radix-ui/react-dialog": "^1.1.6", "@radix-ui/react-id": "^1.1.0", "@radix-ui/react-primitive": "^2.0.2" }, "peerDependencies": { "react": "^18 || ^19 || ^19.0.0-rc", "react-dom": "^18 || ^19 || ^19.0.0-rc" } }, "sha512-Vsv7kFaXm+ptHDMZ7izaRsP70GgrW9NBNGswt9OZaVBLlE0SNpDq8eu/VGXyF9r7M0azK3Wy7OlYXsuyYLFzHg=="],
"color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="],
"color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="],
"combined-stream": ["combined-stream@1.0.8", "", { "dependencies": { "delayed-stream": "~1.0.0" } }, "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg=="],
"commander": ["commander@4.1.1", "", {}, "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA=="],
"concat-map": ["concat-map@0.0.1", "", {}, "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg=="],
"cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="],
"css.escape": ["css.escape@1.5.1", "", {}, "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg=="],
"cssesc": ["cssesc@3.0.0", "", { "bin": "bin/cssesc" }, "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg=="],
"cssom": ["cssom@0.5.0", "", {}, "sha512-iKuQcq+NdHqlAcwUY0o/HL69XQrUaQdMjmStJ8JFmUaiiQErlhrmuigkg/CU4E2J0IyUKUrMAgl36TvN67MqTw=="],
"cssstyle": ["cssstyle@2.3.0", "", { "dependencies": { "cssom": "~0.3.6" } }, "sha512-AZL67abkUzIuvcHqk7c09cezpGNcxUxU4Ioi/05xHk4DQeTkWmGYftIE6ctU6AEt+Gn4n1lDStOtj7FKycP71A=="],
"csstype": ["csstype@3.1.3", "", {}, "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="],
"d3-array": ["d3-array@3.2.4", "", { "dependencies": { "internmap": "1 - 2" } }, "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg=="],
"d3-color": ["d3-color@3.1.0", "", {}, "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA=="],
"d3-ease": ["d3-ease@3.0.1", "", {}, "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w=="],
"d3-format": ["d3-format@3.1.0", "", {}, "sha512-YyUI6AEuY/Wpt8KWLgZHsIU86atmikuoOmCfommt0LYHiQSPjvX2AcFc38PX0CBpr2RCyZhjex+NS/LPOv6YqA=="],
"d3-interpolate": ["d3-interpolate@3.0.1", "", { "dependencies": { "d3-color": "1 - 3" } }, "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g=="],
"d3-path": ["d3-path@3.1.0", "", {}, "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ=="],
"d3-scale": ["d3-scale@4.0.2", "", { "dependencies": { "d3-array": "2.10.0 - 3", "d3-format": "1 - 3", "d3-interpolate": "1.2.0 - 3", "d3-time": "2.1.1 - 3", "d3-time-format": "2 - 4" } }, "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ=="],
"d3-shape": ["d3-shape@3.2.0", "", { "dependencies": { "d3-path": "^3.1.0" } }, "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA=="],
"d3-time": ["d3-time@3.1.0", "", { "dependencies": { "d3-array": "2 - 3" } }, "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q=="],
"d3-time-format": ["d3-time-format@4.1.0", "", { "dependencies": { "d3-time": "1 - 3" } }, "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg=="],
"d3-timer": ["d3-timer@3.0.1", "", {}, "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA=="],
"data-urls": ["data-urls@3.0.2", "", { "dependencies": { "abab": "^2.0.6", "whatwg-mimetype": "^3.0.0", "whatwg-url": "^11.0.0" } }, "sha512-Jy/tj3ldjZJo63sVAvg6LHt2mHvl4V6AgRAmNDtLdm7faqtsx+aJG42rsyCo9JCoRVKwPFzKlIPx3DIibwSIaQ=="],
"date-fns": ["date-fns@3.6.0", "", {}, "sha512-fRHTG8g/Gif+kSh50gaGEdToemgfj74aRX3swtiouboip5JDLAyDE9F11nHMIcvOaXeOC6D7SpNhi7uFyB7Uww=="],
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
"decimal.js": ["decimal.js@10.6.0", "", {}, "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg=="],
"decimal.js-light": ["decimal.js-light@2.5.1", "", {}, "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg=="],
"deep-eql": ["deep-eql@5.0.2", "", {}, "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q=="],
"deep-is": ["deep-is@0.1.4", "", {}, "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ=="],
"delayed-stream": ["delayed-stream@1.0.0", "", {}, "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ=="],
"detect-node-es": ["detect-node-es@1.1.0", "", {}, "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ=="],
"didyoumean": ["didyoumean@1.2.2", "", {}, "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw=="],
"dlv": ["dlv@1.1.3", "", {}, "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA=="],
"dom-accessibility-api": ["dom-accessibility-api@0.6.3", "", {}, "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w=="],
"dom-helpers": ["dom-helpers@5.2.1", "", { "dependencies": { "@babel/runtime": "^7.8.7", "csstype": "^3.0.2" } }, "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA=="],
"domexception": ["domexception@4.0.0", "", { "dependencies": { "webidl-conversions": "^7.0.0" } }, "sha512-A2is4PLG+eeSfoTMA95/s4pvAoSo2mKtiM5jlHkAVewmiO8ISFTFKZjH7UAM1Atli/OT/7JHOrJRJiMKUZKYBw=="],
"dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="],
"eastasianwidth": ["eastasianwidth@0.2.0", "", {}, "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA=="],
"electron-to-chromium": ["electron-to-chromium@1.5.192", "", {}, "sha512-rP8Ez0w7UNw/9j5eSXCe10o1g/8B1P5SM90PCCMVkIRQn2R0LEHWz4Eh9RnxkniuDe1W0cTSOB3MLlkTGDcuCg=="],
"embla-carousel": ["embla-carousel@8.6.0", "", {}, "sha512-SjWyZBHJPbqxHOzckOfo8lHisEaJWmwd23XppYFYVh10bU66/Pn5tkVkbkCMZVdbUE5eTCI2nD8OyIP4Z+uwkA=="],
"embla-carousel-react": ["embla-carousel-react@8.6.0", "", { "dependencies": { "embla-carousel": "8.6.0", "embla-carousel-reactive-utils": "8.6.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.1 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" } }, "sha512-0/PjqU7geVmo6F734pmPqpyHqiM99olvyecY7zdweCw+6tKEXnrE90pBiBbMMU8s5tICemzpQ3hi5EpxzGW+JA=="],
"embla-carousel-reactive-utils": ["embla-carousel-reactive-utils@8.6.0", "", { "peerDependencies": { "embla-carousel": "8.6.0" } }, "sha512-fMVUDUEx0/uIEDM0Mz3dHznDhfX+znCCDCeIophYb1QGVM7YThSWX+wz11zlYwWFOr74b4QLGg0hrGPJeG2s4A=="],
"emoji-regex": ["emoji-regex@9.2.2", "", {}, "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg=="],
"entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="],
"es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="],
"es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="],
"es-module-lexer": ["es-module-lexer@1.7.0", "", {}, "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA=="],
"es-object-atoms": ["es-object-atoms@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA=="],
"es-set-tostringtag": ["es-set-tostringtag@2.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6", "has-tostringtag": "^1.0.2", "hasown": "^2.0.2" } }, "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA=="],
"esbuild": ["esbuild@0.25.0", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.0", "@esbuild/android-arm": "0.25.0", "@esbuild/android-arm64": "0.25.0", "@esbuild/android-x64": "0.25.0", "@esbuild/darwin-arm64": "0.25.0", "@esbuild/darwin-x64": "0.25.0", "@esbuild/freebsd-arm64": "0.25.0", "@esbuild/freebsd-x64": "0.25.0", "@esbuild/linux-arm": "0.25.0", "@esbuild/linux-arm64": "0.25.0", "@esbuild/linux-ia32": "0.25.0", "@esbuild/linux-loong64": "0.25.0", "@esbuild/linux-mips64el": "0.25.0", "@esbuild/linux-ppc64": "0.25.0", "@esbuild/linux-riscv64": "0.25.0", "@esbuild/linux-s390x": "0.25.0", "@esbuild/linux-x64": "0.25.0", "@esbuild/netbsd-arm64": "0.25.0", "@esbuild/netbsd-x64": "0.25.0", "@esbuild/openbsd-arm64": "0.25.0", "@esbuild/openbsd-x64": "0.25.0", "@esbuild/sunos-x64": "0.25.0", "@esbuild/win32-arm64": "0.25.0", "@esbuild/win32-ia32": "0.25.0", "@esbuild/win32-x64": "0.25.0" }, "bin": "bin/esbuild" }, "sha512-BXq5mqc8ltbaN34cDqWuYKyNhX8D/Z0J1xdtdQ8UcIIIyJyz+ZMKUt58tF3SrZ85jcfN/PZYhjR5uDQAYNVbuw=="],
"escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="],
"escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="],
"escodegen": ["escodegen@2.1.0", "", { "dependencies": { "esprima": "^4.0.1", "estraverse": "^5.2.0", "esutils": "^2.0.2" }, "optionalDependencies": { "source-map": "~0.6.1" }, "bin": { "escodegen": "bin/escodegen.js", "esgenerate": "bin/esgenerate.js" } }, "sha512-2NlIDTwUWJN0mRPQOdtQBzbUHvdGY2P1VXSyU83Q3xKxM7WHX2Ql8dKq782Q9TgQUNOLEzEYu9bzLNj1q88I5w=="],
"eslint": ["eslint@9.32.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.12.1", "@eslint/config-array": "^0.21.0", "@eslint/config-helpers": "^0.3.0", "@eslint/core": "^0.15.0", "@eslint/eslintrc": "^3.3.1", "@eslint/js": "9.32.0", "@eslint/plugin-kit": "^0.3.4", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", "@types/json-schema": "^7.0.15", "ajv": "^6.12.4", "chalk": "^4.0.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", "eslint-scope": "^8.4.0", "eslint-visitor-keys": "^4.2.1", "espree": "^10.4.0", "esquery": "^1.5.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^8.0.0", "find-up": "^5.0.0", "glob-parent": "^6.0.2", "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", "lodash.merge": "^4.6.2", "minimatch": "^3.1.2", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, "peerDependencies": { "jiti": "*" }, "bin": "bin/eslint.js" }, "sha512-LSehfdpgMeWcTZkWZVIJl+tkZ2nuSkyyB9C27MZqFWXuph7DvaowgcTvKqxvpLW1JZIk8PN7hFY3Rj9LQ7m7lg=="],
"eslint-plugin-react-hooks": ["eslint-plugin-react-hooks@5.2.0", "", { "peerDependencies": { "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0" } }, "sha512-+f15FfK64YQwZdJNELETdn5ibXEUQmW1DZL6KXhNnc2heoy/sg9VJJeT7n8TlMWouzWqSWavFkIhHyIbIAEapg=="],
"eslint-plugin-react-refresh": ["eslint-plugin-react-refresh@0.4.20", "", { "peerDependencies": { "eslint": ">=8.40" } }, "sha512-XpbHQ2q5gUF8BGOX4dHe+71qoirYMhApEPZ7sfhF/dNnOF1UXnCMGZf79SFTBO7Bz5YEIT4TMieSlJBWhP9WBA=="],
"eslint-scope": ["eslint-scope@8.4.0", "", { "dependencies": { "esrecurse": "^4.3.0", "estraverse": "^5.2.0" } }, "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg=="],
"eslint-visitor-keys": ["eslint-visitor-keys@4.2.1", "", {}, "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ=="],
"espree": ["espree@10.4.0", "", { "dependencies": { "acorn": "^8.15.0", "acorn-jsx": "^5.3.2", "eslint-visitor-keys": "^4.2.1" } }, "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ=="],
"esprima": ["esprima@4.0.1", "", { "bin": { "esparse": "bin/esparse.js", "esvalidate": "bin/esvalidate.js" } }, "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A=="],
"esquery": ["esquery@1.6.0", "", { "dependencies": { "estraverse": "^5.1.0" } }, "sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg=="],
"esrecurse": ["esrecurse@4.3.0", "", { "dependencies": { "estraverse": "^5.2.0" } }, "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag=="],
"estraverse": ["estraverse@5.3.0", "", {}, "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA=="],
"estree-walker": ["estree-walker@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g=="],
"esutils": ["esutils@2.0.3", "", {}, "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="],
"eventemitter3": ["eventemitter3@4.0.7", "", {}, "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw=="],
"expect-type": ["expect-type@1.3.0", "", {}, "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA=="],
"fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="],
"fast-equals": ["fast-equals@5.2.2", "", {}, "sha512-V7/RktU11J3I36Nwq2JnZEM7tNm17eBJz+u25qdxBZeCKiX6BkVSZQjwWIr+IobgnZy+ag73tTZgZi7tr0LrBw=="],
"fast-glob": ["fast-glob@3.3.2", "", { "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", "glob-parent": "^5.1.2", "merge2": "^1.3.0", "micromatch": "^4.0.4" } }, "sha512-oX2ruAFQwf/Orj8m737Y5adxDQO0LAB7/S5MnxCdTNDd4p6BsyIVsv9JQsATbTSq8KHRpLwIHbVlUNatxd+1Ow=="],
"fast-json-stable-stringify": ["fast-json-stable-stringify@2.1.0", "", {}, "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw=="],
"fast-levenshtein": ["fast-levenshtein@2.0.6", "", {}, "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw=="],
"fastq": ["fastq@1.17.1", "", { "dependencies": { "reusify": "^1.0.4" } }, "sha512-sRVD3lWVIXWg6By68ZN7vho9a1pQcN/WBFaAAsDDFzlJjvoGx0P8z7V1t72grFJfJhu3YPZBuu25f7Kaw2jN1w=="],
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" } }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
"file-entry-cache": ["file-entry-cache@8.0.0", "", { "dependencies": { "flat-cache": "^4.0.0" } }, "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ=="],
"fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="],
"find-up": ["find-up@5.0.0", "", { "dependencies": { "locate-path": "^6.0.0", "path-exists": "^4.0.0" } }, "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng=="],
"flat-cache": ["flat-cache@4.0.1", "", { "dependencies": { "flatted": "^3.2.9", "keyv": "^4.5.4" } }, "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw=="],
"flatted": ["flatted@3.3.1", "", {}, "sha512-X8cqMLLie7KsNUDSdzeN8FYK9rEt4Dt67OsG/DNGnYTSDBG4uFAJFBnUeiV+zCVAvwFy56IjM9sH51jVaEhNxw=="],
"foreground-child": ["foreground-child@3.3.0", "", { "dependencies": { "cross-spawn": "^7.0.0", "signal-exit": "^4.0.1" } }, "sha512-Ld2g8rrAyMYFXBhEqMz8ZAHBi4J4uS1i/CxGMDnjyFWddMXLVcDp051DZfu+t7+ab7Wv6SMqpWmyFIj5UbfFvg=="],
"form-data": ["form-data@4.0.5", "", { "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", "hasown": "^2.0.2", "mime-types": "^2.1.12" } }, "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w=="],
"fraction.js": ["fraction.js@4.3.7", "", {}, "sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew=="],
"fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
"function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="],
"get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="],
"get-nonce": ["get-nonce@1.0.1", "", {}, "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q=="],
"get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="],
"glob": ["glob@10.4.5", "", { "dependencies": { "foreground-child": "^3.1.0", "jackspeak": "^3.1.2", "minimatch": "^9.0.4", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^1.11.1" }, "bin": "dist/esm/bin.mjs" }, "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg=="],
"glob-parent": ["glob-parent@6.0.2", "", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="],
"globals": ["globals@15.15.0", "", {}, "sha512-7ACyT3wmyp3I61S4fG682L0VA2RGD9otkqGJIwNUMF1SWUombIIk+af1unuDYgMm082aHYwD+mzJvv9Iu8dsgg=="],
"gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="],
"graphemer": ["graphemer@1.4.0", "", {}, "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag=="],
"has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="],
"has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="],
"has-tostringtag": ["has-tostringtag@1.0.2", "", { "dependencies": { "has-symbols": "^1.0.3" } }, "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw=="],
"hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="],
"html-encoding-sniffer": ["html-encoding-sniffer@3.0.0", "", { "dependencies": { "whatwg-encoding": "^2.0.0" } }, "sha512-oWv4T4yJ52iKrufjnyZPkrN0CH3QnrUqdB6In1g5Fe1mia8GmF36gnfNySxoZtxD5+NmYw1EElVXiBk93UeskA=="],
"http-proxy-agent": ["http-proxy-agent@5.0.0", "", { "dependencies": { "@tootallnate/once": "2", "agent-base": "6", "debug": "4" } }, "sha512-n2hY8YdoRE1i7r6M0w9DIw5GgZN0G25P8zLCRQ8rjXtTU3vsNFBI/vWK/UIeE6g5MUUz6avwAPXmL6Fy9D/90w=="],
"https-proxy-agent": ["https-proxy-agent@5.0.1", "", { "dependencies": { "agent-base": "6", "debug": "4" } }, "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA=="],
"iceberg-js": ["iceberg-js@0.8.1", "", {}, "sha512-1dhVQZXhcHje7798IVM+xoo/1ZdVfzOMIc8/rgVSijRK38EDqOJoGula9N/8ZI5RD8QTxNQtK/Gozpr+qUqRRA=="],
"iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="],
"ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="],
"import-fresh": ["import-fresh@3.3.1", "", { "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" } }, "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ=="],
"imurmurhash": ["imurmurhash@0.1.4", "", {}, "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA=="],
"indent-string": ["indent-string@4.0.0", "", {}, "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg=="],
"input-otp": ["input-otp@1.4.2", "", { "peerDependencies": { "react": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc" } }, "sha512-l3jWwYNvrEa6NTCt7BECfCm48GvwuZzkoeG3gBL2w4CHeOXW3eKFmf9UNYkNfYc3mxMrthMnxjIE07MT0zLBQA=="],
"internmap": ["internmap@2.0.3", "", {}, "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg=="],
"is-binary-path": ["is-binary-path@2.1.0", "", { "dependencies": { "binary-extensions": "^2.0.0" } }, "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw=="],
"is-core-module": ["is-core-module@2.15.1", "", { "dependencies": { "hasown": "^2.0.2" } }, "sha512-z0vtXSwucUJtANQWldhbtbt7BnL0vxiFjIdDLAatwhDYty2bad6s+rijD6Ri4YuYJubLzIJLUidCh09e1djEVQ=="],
"is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="],
"is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="],
"is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="],
"is-number": ["is-number@7.0.0", "", {}, "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng=="],
"is-potential-custom-element-name": ["is-potential-custom-element-name@1.0.1", "", {}, "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ=="],
"isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="],
"jackspeak": ["jackspeak@3.4.3", "", { "dependencies": { "@isaacs/cliui": "^8.0.2" }, "optionalDependencies": { "@pkgjs/parseargs": "^0.11.0" } }, "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw=="],
"jiti": ["jiti@1.21.6", "", { "bin": "bin/jiti.js" }, "sha512-2yTgeWTWzMWkHu6Jp9NKgePDaYHbntiwvYuuJLbbN9vl7DC9DvXKOB2BC3ZZ92D3cvV/aflH0osDfwpHepQ53w=="],
"js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="],
"js-yaml": ["js-yaml@4.1.0", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": "bin/js-yaml.js" }, "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA=="],
"jsdom": ["jsdom@20.0.3", "", { "dependencies": { "abab": "^2.0.6", "acorn": "^8.8.1", "acorn-globals": "^7.0.0", "cssom": "^0.5.0", "cssstyle": "^2.3.0", "data-urls": "^3.0.2", "decimal.js": "^10.4.2", "domexception": "^4.0.0", "escodegen": "^2.0.0", "form-data": "^4.0.0", "html-encoding-sniffer": "^3.0.0", "http-proxy-agent": "^5.0.0", "https-proxy-agent": "^5.0.1", "is-potential-custom-element-name": "^1.0.1", "nwsapi": "^2.2.2", "parse5": "^7.1.1", "saxes": "^6.0.0", "symbol-tree": "^3.2.4", "tough-cookie": "^4.1.2", "w3c-xmlserializer": "^4.0.0", "webidl-conversions": "^7.0.0", "whatwg-encoding": "^2.0.0", "whatwg-mimetype": "^3.0.0", "whatwg-url": "^11.0.0", "ws": "^8.11.0", "xml-name-validator": "^4.0.0" }, "peerDependencies": { "canvas": "^2.5.0" }, "optionalPeers": ["canvas"] }, "sha512-SYhBvTh89tTfCD/CRdSOm13mOBa42iTaTyfyEWBdKcGdPxPtLFBXuHR8XHb33YNYaP+lLbmSvBTsnoesCNJEsQ=="],
"json-buffer": ["json-buffer@3.0.1", "", {}, "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ=="],
"json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="],
"json-stable-stringify-without-jsonify": ["json-stable-stringify-without-jsonify@1.0.1", "", {}, "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw=="],
"keyv": ["keyv@4.5.4", "", { "dependencies": { "json-buffer": "3.0.1" } }, "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw=="],
"levn": ["levn@0.4.1", "", { "dependencies": { "prelude-ls": "^1.2.1", "type-check": "~0.4.0" } }, "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ=="],
"lilconfig": ["lilconfig@3.1.3", "", {}, "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw=="],
"lines-and-columns": ["lines-and-columns@1.2.4", "", {}, "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg=="],
"locate-path": ["locate-path@6.0.0", "", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="],
"lodash": ["lodash@4.17.21", "", {}, "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="],
"lodash.castarray": ["lodash.castarray@4.4.0", "", {}, "sha512-aVx8ztPv7/2ULbArGJ2Y42bG1mEQ5mGjpdvrbJcJFU3TbYybe+QlLS4pst9zV52ymy2in1KpFPiZnAOATxD4+Q=="],
"lodash.isplainobject": ["lodash.isplainobject@4.0.6", "", {}, "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA=="],
"lodash.merge": ["lodash.merge@4.6.2", "", {}, "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ=="],
"loose-envify": ["loose-envify@1.4.0", "", { "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" }, "bin": "cli.js" }, "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q=="],
"loupe": ["loupe@3.2.1", "", {}, "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ=="],
"lovable-tagger": ["lovable-tagger@1.1.13", "", { "dependencies": { "esbuild": "^0.25.0", "tailwindcss": "^3.4.17" }, "peerDependencies": { "vite": ">=5.0.0 <8.0.0" } }, "sha512-RBEYDxao7Xf8ya29L0cd+ocE7Gs80xPOIOwwck65Hoie8YDKViuXi3UYV14DoNWIvaJ7WVPf7SG3cc844nFqGA=="],
"lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="],
"lucide-react": ["lucide-react@0.462.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0-rc" } }, "sha512-NTL7EbAao9IFtuSivSZgrAh4fZd09Lr+6MTkqIxuHaH2nnYiYIzXPo06cOxHg9wKLdj6LL8TByG4qpePqwgx/g=="],
"magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
"math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="],
"merge2": ["merge2@1.4.1", "", {}, "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg=="],
"micromatch": ["micromatch@4.0.8", "", { "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" } }, "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA=="],
"mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="],
"mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="],
"min-indent": ["min-indent@1.0.1", "", {}, "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg=="],
"minimatch": ["minimatch@3.1.2", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw=="],
"minipass": ["minipass@7.1.2", "", {}, "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw=="],
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
"mz": ["mz@2.7.0", "", { "dependencies": { "any-promise": "^1.0.0", "object-assign": "^4.0.1", "thenify-all": "^1.0.0" } }, "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q=="],
"nanoid": ["nanoid@3.3.11", "", { "bin": "bin/nanoid.cjs" }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
"natural-compare": ["natural-compare@1.4.0", "", {}, "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw=="],
"next-themes": ["next-themes@0.3.0", "", { "peerDependencies": { "react": "^16.8 || ^17 || ^18", "react-dom": "^16.8 || ^17 || ^18" } }, "sha512-/QHIrsYpd6Kfk7xakK4svpDI5mmXP0gfvCoJdGpZQ2TOrQZmsW0QxjaiLn8wbIKjtm4BTSqLoix4lxYYOnLJ/w=="],
"node-releases": ["node-releases@2.0.19", "", {}, "sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw=="],
"normalize-path": ["normalize-path@3.0.0", "", {}, "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA=="],
"normalize-range": ["normalize-range@0.1.2", "", {}, "sha512-bdok/XvKII3nUpklnV6P2hxtMNrCboOjAcyBuQnWEhO665FwrSNRxU+AqpsyvO6LgGYPspN+lu5CLtw4jPRKNA=="],
"nwsapi": ["nwsapi@2.2.23", "", {}, "sha512-7wfH4sLbt4M0gCDzGE6vzQBo0bfTKjU7Sfpqy/7gs1qBfYz2vEJH6vXcBKpO3+6Yu1telwd0t9HpyOoLEQQbIQ=="],
"object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="],
"object-hash": ["object-hash@3.0.0", "", {}, "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw=="],
"optionator": ["optionator@0.9.4", "", { "dependencies": { "deep-is": "^0.1.3", "fast-levenshtein": "^2.0.6", "levn": "^0.4.1", "prelude-ls": "^1.2.1", "type-check": "^0.4.0", "word-wrap": "^1.2.5" } }, "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g=="],
"p-limit": ["p-limit@3.1.0", "", { "dependencies": { "yocto-queue": "^0.1.0" } }, "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ=="],
"p-locate": ["p-locate@5.0.0", "", { "dependencies": { "p-limit": "^3.0.2" } }, "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw=="],
"package-json-from-dist": ["package-json-from-dist@1.0.1", "", {}, "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw=="],
"parent-module": ["parent-module@1.0.1", "", { "dependencies": { "callsites": "^3.0.0" } }, "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g=="],
"parse5": ["parse5@7.3.0", "", { "dependencies": { "entities": "^6.0.0" } }, "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw=="],
"path-exists": ["path-exists@4.0.0", "", {}, "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w=="],
"path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="],
"path-parse": ["path-parse@1.0.7", "", {}, "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw=="],
"path-scurry": ["path-scurry@1.11.1", "", { "dependencies": { "lru-cache": "^10.2.0", "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" } }, "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA=="],
"pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="],
"pathval": ["pathval@2.0.1", "", {}, "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ=="],
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
"picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="],
"pify": ["pify@2.3.0", "", {}, "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog=="],
"pirates": ["pirates@4.0.6", "", {}, "sha512-saLsH7WeYYPiD25LDuLRRY/i+6HaPYr6G1OUlN39otzkSTxKnubR9RTxS3/Kk50s1g2JTgFwWQDQyplC5/SHZg=="],
"postcss": ["postcss@8.5.6", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="],
"postcss-import": ["postcss-import@15.1.0", "", { "dependencies": { "postcss-value-parser": "^4.0.0", "read-cache": "^1.0.0", "resolve": "^1.1.7" }, "peerDependencies": { "postcss": "^8.0.0" } }, "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew=="],
"postcss-js": ["postcss-js@4.0.1", "", { "dependencies": { "camelcase-css": "^2.0.1" }, "peerDependencies": { "postcss": "^8.4.21" } }, "sha512-dDLF8pEO191hJMtlHFPRa8xsizHaM82MLfNkUHdUtVEV3tgTp5oj+8qbEqYM57SLfc74KSbw//4SeJma2LRVIw=="],
"postcss-load-config": ["postcss-load-config@4.0.2", "", { "dependencies": { "lilconfig": "^3.0.0", "yaml": "^2.3.4" }, "peerDependencies": { "postcss": ">=8.0.9", "ts-node": ">=9.0.0" }, "optionalPeers": ["ts-node"] }, "sha512-bSVhyJGL00wMVoPUzAVAnbEoWyqRxkjv64tUl427SKnPrENtq6hJwUojroMz2VB+Q1edmi4IfrAPpami5VVgMQ=="],
"postcss-nested": ["postcss-nested@6.2.0", "", { "dependencies": { "postcss-selector-parser": "^6.1.1" }, "peerDependencies": { "postcss": "^8.2.14" } }, "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ=="],
"postcss-selector-parser": ["postcss-selector-parser@6.0.10", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w=="],
"postcss-value-parser": ["postcss-value-parser@4.2.0", "", {}, "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ=="],
"prelude-ls": ["prelude-ls@1.2.1", "", {}, "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="],
"prop-types": ["prop-types@15.8.1", "", { "dependencies": { "loose-envify": "^1.4.0", "object-assign": "^4.1.1", "react-is": "^16.13.1" } }, "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg=="],
"psl": ["psl@1.15.0", "", { "dependencies": { "punycode": "^2.3.1" } }, "sha512-JZd3gMVBAVQkSs6HdNZo9Sdo0LNcQeMNP3CozBJb3JYC/QUYZTnKxP+f8oWRX4rHP5EurWxqAHTSwUCjlNKa1w=="],
"punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="],
"querystringify": ["querystringify@2.2.0", "", {}, "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ=="],
"queue-microtask": ["queue-microtask@1.2.3", "", {}, "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="],
"react": ["react@18.3.1", "", { "dependencies": { "loose-envify": "^1.1.0" } }, "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ=="],
"react-day-picker": ["react-day-picker@8.10.1", "", { "peerDependencies": { "date-fns": "^2.28.0 || ^3.0.0", "react": "^16.8.0 || ^17.0.0 || ^18.0.0" } }, "sha512-TMx7fNbhLk15eqcMt+7Z7S2KF7mfTId/XJDjKE8f+IUcFn0l08/kI4FiYTL/0yuOLmEcbR4Fwe3GJf/NiiMnPA=="],
"react-dom": ["react-dom@18.3.1", "", { "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" }, "peerDependencies": { "react": "^18.3.1" } }, "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw=="],
"react-hook-form": ["react-hook-form@7.61.1", "", { "peerDependencies": { "react": "^16.8.0 || ^17 || ^18 || ^19" } }, "sha512-2vbXUFDYgqEgM2RcXcAT2PwDW/80QARi+PKmHy5q2KhuKvOlG8iIYgf7eIlIANR5trW9fJbP4r5aub3a4egsew=="],
"react-is": ["react-is@18.3.1", "", {}, "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg=="],
"react-remove-scroll": ["react-remove-scroll@2.7.1", "", { "dependencies": { "react-remove-scroll-bar": "^2.3.7", "react-style-singleton": "^2.2.3", "tslib": "^2.1.0", "use-callback-ref": "^1.3.3", "use-sidecar": "^1.1.3" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" } }, "sha512-HpMh8+oahmIdOuS5aFKKY6Pyog+FNaZV/XyJOq7b4YFwsFHe5yYfdbIalI4k3vU2nSDql7YskmUseHsRrJqIPA=="],
"react-remove-scroll-bar": ["react-remove-scroll-bar@2.3.8", "", { "dependencies": { "react-style-singleton": "^2.2.2", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q=="],
"react-resizable-panels": ["react-resizable-panels@2.1.9", "", { "peerDependencies": { "react": "^16.14.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc", "react-dom": "^16.14.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" } }, "sha512-z77+X08YDIrgAes4jl8xhnUu1LNIRp4+E7cv4xHmLOxxUPO/ML7PSrE813b90vj7xvQ1lcf7g2uA9GeMZonjhQ=="],
"react-router": ["react-router@6.30.1", "", { "dependencies": { "@remix-run/router": "1.23.0" }, "peerDependencies": { "react": ">=16.8" } }, "sha512-X1m21aEmxGXqENEPG3T6u0Th7g0aS4ZmoNynhbs+Cn+q+QGTLt+d5IQ2bHAXKzKcxGJjxACpVbnYQSCRcfxHlQ=="],
"react-router-dom": ["react-router-dom@6.30.1", "", { "dependencies": { "@remix-run/router": "1.23.0", "react-router": "6.30.1" }, "peerDependencies": { "react": ">=16.8", "react-dom": ">=16.8" } }, "sha512-llKsgOkZdbPU1Eg3zK8lCn+sjD9wMRZZPuzmdWWX5SUs8OFkN5HnFVC0u5KMeMaC9aoancFI/KoLuKPqN+hxHw=="],
"react-smooth": ["react-smooth@4.0.4", "", { "dependencies": { "fast-equals": "^5.0.1", "prop-types": "^15.8.1", "react-transition-group": "^4.4.5" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-gnGKTpYwqL0Iii09gHobNolvX4Kiq4PKx6eWBCYYix+8cdw+cGo3do906l1NBPKkSWx1DghC1dlWG9L2uGd61Q=="],
"react-style-singleton": ["react-style-singleton@2.2.3", "", { "dependencies": { "get-nonce": "^1.0.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" } }, "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ=="],
"react-transition-group": ["react-transition-group@4.4.5", "", { "dependencies": { "@babel/runtime": "^7.5.5", "dom-helpers": "^5.0.1", "loose-envify": "^1.4.0", "prop-types": "^15.6.2" }, "peerDependencies": { "react": ">=16.6.0", "react-dom": ">=16.6.0" } }, "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g=="],
"read-cache": ["read-cache@1.0.0", "", { "dependencies": { "pify": "^2.3.0" } }, "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA=="],
"readdirp": ["readdirp@3.6.0", "", { "dependencies": { "picomatch": "^2.2.1" } }, "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA=="],
"recharts": ["recharts@2.15.4", "", { "dependencies": { "clsx": "^2.0.0", "eventemitter3": "^4.0.1", "lodash": "^4.17.21", "react-is": "^18.3.1", "react-smooth": "^4.0.4", "recharts-scale": "^0.4.4", "tiny-invariant": "^1.3.1", "victory-vendor": "^36.6.8" }, "peerDependencies": { "react": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-UT/q6fwS3c1dHbXv2uFgYJ9BMFHu3fwnd7AYZaEQhXuYQ4hgsxLvsUXzGdKeZrW5xopzDCvuA2N41WJ88I7zIw=="],
"recharts-scale": ["recharts-scale@0.4.5", "", { "dependencies": { "decimal.js-light": "^2.4.1" } }, "sha512-kivNFO+0OcUNu7jQquLXAxz1FIwZj8nrj+YkOKc5694NbjCvcT6aSZiIzNzd2Kul4o4rTto8QVR9lMNtxD4G1w=="],
"redent": ["redent@3.0.0", "", { "dependencies": { "indent-string": "^4.0.0", "strip-indent": "^3.0.0" } }, "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg=="],
"requires-port": ["requires-port@1.0.0", "", {}, "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ=="],
"resolve": ["resolve@1.22.8", "", { "dependencies": { "is-core-module": "^2.13.0", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": "bin/resolve" }, "sha512-oKWePCxqpd6FlLvGV1VU0x7bkPmmCNolxzjMf4NczoDnQcIWrAF+cPtZn5i6n+RfD2d9i0tzpKnG6Yk168yIyw=="],
"resolve-from": ["resolve-from@4.0.0", "", {}, "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="],
"reusify": ["reusify@1.0.4", "", {}, "sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw=="],
"rollup": ["rollup@4.24.0", "", { "dependencies": { "@types/estree": "1.0.6" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.24.0", "@rollup/rollup-android-arm64": "4.24.0", "@rollup/rollup-darwin-arm64": "4.24.0", "@rollup/rollup-darwin-x64": "4.24.0", "@rollup/rollup-linux-arm-gnueabihf": "4.24.0", "@rollup/rollup-linux-arm-musleabihf": "4.24.0", "@rollup/rollup-linux-arm64-gnu": "4.24.0", "@rollup/rollup-linux-arm64-musl": "4.24.0", "@rollup/rollup-linux-powerpc64le-gnu": "4.24.0", "@rollup/rollup-linux-riscv64-gnu": "4.24.0", "@rollup/rollup-linux-s390x-gnu": "4.24.0", "@rollup/rollup-linux-x64-gnu": "4.24.0", "@rollup/rollup-linux-x64-musl": "4.24.0", "@rollup/rollup-win32-arm64-msvc": "4.24.0", "@rollup/rollup-win32-ia32-msvc": "4.24.0", "@rollup/rollup-win32-x64-msvc": "4.24.0", "fsevents": "~2.3.2" }, "bin": "dist/bin/rollup" }, "sha512-DOmrlGSXNk1DM0ljiQA+i+o0rSLhtii1je5wgk60j49d1jHT5YYttBv1iWOnYSTG+fZZESUOSNiAl89SIet+Cg=="],
"run-parallel": ["run-parallel@1.2.0", "", { "dependencies": { "queue-microtask": "^1.2.2" } }, "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA=="],
"safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="],
"saxes": ["saxes@6.0.0", "", { "dependencies": { "xmlchars": "^2.2.0" } }, "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA=="],
"scheduler": ["scheduler@0.23.2", "", { "dependencies": { "loose-envify": "^1.1.0" } }, "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ=="],
"semver": ["semver@7.7.2", "", { "bin": "bin/semver.js" }, "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA=="],
"shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="],
"shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="],
"siginfo": ["siginfo@2.0.0", "", {}, "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g=="],
"signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="],
"sonner": ["sonner@1.7.4", "", { "peerDependencies": { "react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc", "react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc" } }, "sha512-DIS8z4PfJRbIyfVFDVnK9rO3eYDtse4Omcm6bt0oEr5/jtLgysmjuBl1frJ9E/EQZrFmKx2A8m/s5s9CRXIzhw=="],
"source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="],
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
"stackback": ["stackback@0.0.2", "", {}, "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw=="],
"std-env": ["std-env@3.10.0", "", {}, "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg=="],
"string-width": ["string-width@5.1.2", "", { "dependencies": { "eastasianwidth": "^0.2.0", "emoji-regex": "^9.2.2", "strip-ansi": "^7.0.1" } }, "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA=="],
"string-width-cjs": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
"strip-ansi": ["strip-ansi@7.1.0", "", { "dependencies": { "ansi-regex": "^6.0.1" } }, "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ=="],
"strip-ansi-cjs": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
"strip-indent": ["strip-indent@3.0.0", "", { "dependencies": { "min-indent": "^1.0.0" } }, "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ=="],
"strip-json-comments": ["strip-json-comments@3.1.1", "", {}, "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig=="],
"strip-literal": ["strip-literal@3.1.0", "", { "dependencies": { "js-tokens": "^9.0.1" } }, "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg=="],
"sucrase": ["sucrase@3.35.0", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.2", "commander": "^4.0.0", "glob": "^10.3.10", "lines-and-columns": "^1.1.6", "mz": "^2.7.0", "pirates": "^4.0.1", "ts-interface-checker": "^0.1.9" }, "bin": { "sucrase": "bin/sucrase", "sucrase-node": "bin/sucrase-node" } }, "sha512-8EbVDiu9iN/nESwxeSxDKe0dunta1GOlHufmSSXxMD2z2/tMZpDMpvXQGsc+ajGo8y2uYUmixaSRUc/QPoQ0GA=="],
"supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="],
"supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="],
"symbol-tree": ["symbol-tree@3.2.4", "", {}, "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw=="],
"tailwind-merge": ["tailwind-merge@2.6.0", "", {}, "sha512-P+Vu1qXfzediirmHOC3xKGAYeZtPcV9g76X+xg2FD4tYgR71ewMA35Y3sCz3zhiN/dwefRpJX0yBcgwi1fXNQA=="],
"tailwindcss": ["tailwindcss@3.4.17", "", { "dependencies": { "@alloc/quick-lru": "^5.2.0", "arg": "^5.0.2", "chokidar": "^3.6.0", "didyoumean": "^1.2.2", "dlv": "^1.1.3", "fast-glob": "^3.3.2", "glob-parent": "^6.0.2", "is-glob": "^4.0.3", "jiti": "^1.21.6", "lilconfig": "^3.1.3", "micromatch": "^4.0.8", "normalize-path": "^3.0.0", "object-hash": "^3.0.0", "picocolors": "^1.1.1", "postcss": "^8.4.47", "postcss-import": "^15.1.0", "postcss-js": "^4.0.1", "postcss-load-config": "^4.0.2", "postcss-nested": "^6.2.0", "postcss-selector-parser": "^6.1.2", "resolve": "^1.22.8", "sucrase": "^3.35.0" }, "bin": { "tailwind": "lib/cli.js", "tailwindcss": "lib/cli.js" } }, "sha512-w33E2aCvSDP0tW9RZuNXadXlkHXqFzSkQew/aIa2i/Sj8fThxwovwlXHSPXTbAHwEIhBFXAedUhP2tueAKP8Og=="],
"tailwindcss-animate": ["tailwindcss-animate@1.0.7", "", { "peerDependencies": { "tailwindcss": ">=3.0.0 || insiders" } }, "sha512-bl6mpH3T7I3UFxuvDEXLxy/VuFxBk5bbzplh7tXI68mwMokNYd1t9qPBHlnyTwfa4JGC4zP516I1hYYtQ/vspA=="],
"thenify": ["thenify@3.3.1", "", { "dependencies": { "any-promise": "^1.0.0" } }, "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw=="],
"thenify-all": ["thenify-all@1.6.0", "", { "dependencies": { "thenify": ">= 3.1.0 < 4" } }, "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA=="],
"tiny-invariant": ["tiny-invariant@1.3.3", "", {}, "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg=="],
"tinybench": ["tinybench@2.9.0", "", {}, "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg=="],
"tinyexec": ["tinyexec@0.3.2", "", {}, "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA=="],
"tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="],
"tinypool": ["tinypool@1.1.1", "", {}, "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg=="],
"tinyrainbow": ["tinyrainbow@2.0.0", "", {}, "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw=="],
"tinyspy": ["tinyspy@4.0.4", "", {}, "sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q=="],
"to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="],
"tough-cookie": ["tough-cookie@4.1.4", "", { "dependencies": { "psl": "^1.1.33", "punycode": "^2.1.1", "universalify": "^0.2.0", "url-parse": "^1.5.3" } }, "sha512-Loo5UUvLD9ScZ6jh8beX1T6sO1w2/MpCRpEP7V280GKMVUQ0Jzar2U3UJPsrdbziLEMMhu3Ujnq//rhiFuIeag=="],
"tr46": ["tr46@3.0.0", "", { "dependencies": { "punycode": "^2.1.1" } }, "sha512-l7FvfAHlcmulp8kr+flpQZmVwtu7nfRV7NZujtN0OqES8EL4O4e0qqzL0DC5gAvx/ZC/9lk6rhcUwYvkBnBnYA=="],
"ts-api-utils": ["ts-api-utils@2.1.0", "", { "peerDependencies": { "typescript": ">=4.8.4" } }, "sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ=="],
"ts-interface-checker": ["ts-interface-checker@0.1.13", "", {}, "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA=="],
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
"type-check": ["type-check@0.4.0", "", { "dependencies": { "prelude-ls": "^1.2.1" } }, "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew=="],
"typescript": ["typescript@5.8.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ=="],
"typescript-eslint": ["typescript-eslint@8.38.0", "", { "dependencies": { "@typescript-eslint/eslint-plugin": "8.38.0", "@typescript-eslint/parser": "8.38.0", "@typescript-eslint/typescript-estree": "8.38.0", "@typescript-eslint/utils": "8.38.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <5.9.0" } }, "sha512-FsZlrYK6bPDGoLeZRuvx2v6qrM03I0U0SnfCLPs/XCCPCFD80xU9Pg09H/K+XFa68uJuZo7l/Xhs+eDRg2l3hg=="],
"undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="],
"universalify": ["universalify@0.2.0", "", {}, "sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg=="],
"update-browserslist-db": ["update-browserslist-db@1.1.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": "cli.js" }, "sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw=="],
"uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="],
"url-parse": ["url-parse@1.5.10", "", { "dependencies": { "querystringify": "^2.1.1", "requires-port": "^1.0.0" } }, "sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ=="],
"use-callback-ref": ["use-callback-ref@1.3.3", "", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" } }, "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg=="],
"use-sidecar": ["use-sidecar@1.1.3", "", { "dependencies": { "detect-node-es": "^1.1.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" } }, "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ=="],
"use-sync-external-store": ["use-sync-external-store@1.5.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Rb46I4cGGVBmjamjphe8L/UnvJD+uPPtTkNvX5mZgqdbavhI4EbgIWJiIHXJ8bc/i9EQGPRh4DwEURJ552Do0A=="],
"util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="],
"vaul": ["vaul@0.9.9", "", { "dependencies": { "@radix-ui/react-dialog": "^1.1.1" }, "peerDependencies": { "react": "^16.8 || ^17.0 || ^18.0", "react-dom": "^16.8 || ^17.0 || ^18.0" } }, "sha512-7afKg48srluhZwIkaU+lgGtFCUsYBSGOl8vcc8N/M3YQlZFlynHD15AE+pwrYdc826o7nrIND4lL9Y6b9WWZZQ=="],
"victory-vendor": ["victory-vendor@36.9.2", "", { "dependencies": { "@types/d3-array": "^3.0.3", "@types/d3-ease": "^3.0.0", "@types/d3-interpolate": "^3.0.1", "@types/d3-scale": "^4.0.2", "@types/d3-shape": "^3.1.0", "@types/d3-time": "^3.0.0", "@types/d3-timer": "^3.0.0", "d3-array": "^3.1.6", "d3-ease": "^3.0.1", "d3-interpolate": "^3.0.1", "d3-scale": "^4.0.2", "d3-shape": "^3.1.0", "d3-time": "^3.0.0", "d3-timer": "^3.0.1" } }, "sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ=="],
"vite": ["vite@5.4.19", "", { "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.43", "rollup": "^4.20.0" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || >=20.0.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.4.0" }, "optionalPeers": ["less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser"], "bin": "bin/vite.js" }, "sha512-qO3aKv3HoQC8QKiNSTuUM1l9o/XX3+c+VTgLHbJWHZGeTPVAg2XwazI9UWzoxjIJCGCV2zU60uqMzjeLZuULqA=="],
"vite-node": ["vite-node@3.2.4", "", { "dependencies": { "cac": "^6.7.14", "debug": "^4.4.1", "es-module-lexer": "^1.7.0", "pathe": "^2.0.3", "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" }, "bin": "vite-node.mjs" }, "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg=="],
"vitest": ["vitest@3.2.4", "", { "dependencies": { "@types/chai": "^5.2.2", "@vitest/expect": "3.2.4", "@vitest/mocker": "3.2.4", "@vitest/pretty-format": "^3.2.4", "@vitest/runner": "3.2.4", "@vitest/snapshot": "3.2.4", "@vitest/spy": "3.2.4", "@vitest/utils": "3.2.4", "chai": "^5.2.0", "debug": "^4.4.1", "expect-type": "^1.2.1", "magic-string": "^0.30.17", "pathe": "^2.0.3", "picomatch": "^4.0.2", "std-env": "^3.9.0", "tinybench": "^2.9.0", "tinyexec": "^0.3.2", "tinyglobby": "^0.2.14", "tinypool": "^1.1.1", "tinyrainbow": "^2.0.0", "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", "vite-node": "3.2.4", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@types/debug": "^4.1.12", "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "@vitest/browser": "3.2.4", "@vitest/ui": "3.2.4", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@types/debug", "@vitest/browser", "@vitest/ui", "happy-dom"], "bin": "vitest.mjs" }, "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A=="],
"w3c-xmlserializer": ["w3c-xmlserializer@4.0.0", "", { "dependencies": { "xml-name-validator": "^4.0.0" } }, "sha512-d+BFHzbiCx6zGfz0HyQ6Rg69w9k19nviJspaj4yNscGjrHu94sVP+aRm75yEbCh+r2/yR+7q6hux9LVtbuTGBw=="],
"webidl-conversions": ["webidl-conversions@7.0.0", "", {}, "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g=="],
"whatwg-encoding": ["whatwg-encoding@2.0.0", "", { "dependencies": { "iconv-lite": "0.6.3" } }, "sha512-p41ogyeMUrw3jWclHWTQg1k05DSVXPLcVxRTYsXUk+ZooOCZLcoYgPZ/HL/D/N+uQPOtcp1me1WhBEaX02mhWg=="],
"whatwg-mimetype": ["whatwg-mimetype@3.0.0", "", {}, "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q=="],
"whatwg-url": ["whatwg-url@11.0.0", "", { "dependencies": { "tr46": "^3.0.0", "webidl-conversions": "^7.0.0" } }, "sha512-RKT8HExMpoYx4igMiVMY83lN6UeITKJlBQ+vR/8ZJ8OCdSiN3RwCq+9gH0+Xzj0+5IrM6i4j/6LuvzbZIQgEcQ=="],
"which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
"why-is-node-running": ["why-is-node-running@2.3.0", "", { "dependencies": { "siginfo": "^2.0.0", "stackback": "0.0.2" }, "bin": "cli.js" }, "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w=="],
"word-wrap": ["word-wrap@1.2.5", "", {}, "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA=="],
"wrap-ansi": ["wrap-ansi@8.1.0", "", { "dependencies": { "ansi-styles": "^6.1.0", "string-width": "^5.0.1", "strip-ansi": "^7.0.1" } }, "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ=="],
"wrap-ansi-cjs": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="],
"ws": ["ws@8.19.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg=="],
"xml-name-validator": ["xml-name-validator@4.0.0", "", {}, "sha512-ICP2e+jsHvAj2E2lIHxa5tjXRlKDJo4IdvPvCXbXQGdzSfmSpNVyIKMvoZHjDY9DP0zV17iI85o90vRFXNccRw=="],
"xmlchars": ["xmlchars@2.2.0", "", {}, "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw=="],
"yaml": ["yaml@2.6.0", "", { "bin": "bin.mjs" }, "sha512-a6ae//JvKDEra2kdi1qzCyrJW/WZCgFi8ydDV+eXExl95t+5R+ijnqHJbz9tmMh8FUjx3iv2fCQ4dclAQlO2UQ=="],
"yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="],
"zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
"zustand": ["zustand@5.0.10", "", { "peerDependencies": { "@types/react": ">=18.0.0", "immer": ">=9.0.6", "react": ">=18.0.0", "use-sync-external-store": ">=1.2.0" }, "optionalPeers": ["immer"] }, "sha512-U1AiltS1O9hSy3rul+Ub82ut2fqIAefiSuwECWt6jlMVUGejvf+5omLcRBSzqbRagSM3hQZbtzdeRc6QVScXTg=="],
"@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="],
"@eslint/eslintrc/globals": ["globals@14.0.0", "", {}, "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ=="],
"@humanfs/node/@humanwhocodes/retry": ["@humanwhocodes/retry@0.3.1", "", {}, "sha512-JBxkERygn7Bv/GbN5Rv8Ul6LVknS+5Bp6RgDC/O8gEBU/yeH5Ui5C/OlWrTb6qct7LjjfT6Re2NxB0ln0yYybA=="],
"@typescript-eslint/eslint-plugin/ignore": ["ignore@7.0.5", "", {}, "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg=="],
"@typescript-eslint/typescript-estree/minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="],
"anymatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
"chokidar/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
"cssstyle/cssom": ["cssom@0.3.8", "", {}, "sha512-b0tGHbfegbhPJpxpiBPU2sCkigAqtM9O121le6bbOlgyV+NyGyCmVfJ6QW9eRjz8CpNfWEOYBIMIGRYkLwsIYg=="],
"fast-glob/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
"glob/minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="],
"micromatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
"postcss-nested/postcss-selector-parser": ["postcss-selector-parser@6.1.2", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg=="],
"prop-types/react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="],
"readdirp/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
"string-width-cjs/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
"string-width-cjs/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
"strip-ansi-cjs/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
"strip-literal/js-tokens": ["js-tokens@9.0.1", "", {}, "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ=="],
"tailwindcss/postcss-selector-parser": ["postcss-selector-parser@6.1.2", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg=="],
"vite/esbuild": ["esbuild@0.21.5", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.21.5", "@esbuild/android-arm": "0.21.5", "@esbuild/android-arm64": "0.21.5", "@esbuild/android-x64": "0.21.5", "@esbuild/darwin-arm64": "0.21.5", "@esbuild/darwin-x64": "0.21.5", "@esbuild/freebsd-arm64": "0.21.5", "@esbuild/freebsd-x64": "0.21.5", "@esbuild/linux-arm": "0.21.5", "@esbuild/linux-arm64": "0.21.5", "@esbuild/linux-ia32": "0.21.5", "@esbuild/linux-loong64": "0.21.5", "@esbuild/linux-mips64el": "0.21.5", "@esbuild/linux-ppc64": "0.21.5", "@esbuild/linux-riscv64": "0.21.5", "@esbuild/linux-s390x": "0.21.5", "@esbuild/linux-x64": "0.21.5", "@esbuild/netbsd-x64": "0.21.5", "@esbuild/openbsd-x64": "0.21.5", "@esbuild/sunos-x64": "0.21.5", "@esbuild/win32-arm64": "0.21.5", "@esbuild/win32-ia32": "0.21.5", "@esbuild/win32-x64": "0.21.5" }, "bin": "bin/esbuild" }, "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw=="],
"wrap-ansi/ansi-styles": ["ansi-styles@6.2.1", "", {}, "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug=="],
"wrap-ansi-cjs/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
"wrap-ansi-cjs/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
"@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="],
"glob/minimatch/brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="],
"string-width-cjs/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
"vite/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.21.5", "", { "os": "aix", "cpu": "ppc64" }, "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ=="],
"vite/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.21.5", "", { "os": "android", "cpu": "arm" }, "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg=="],
"vite/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.21.5", "", { "os": "android", "cpu": "arm64" }, "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A=="],
"vite/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.21.5", "", { "os": "android", "cpu": "x64" }, "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA=="],
"vite/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.21.5", "", { "os": "darwin", "cpu": "arm64" }, "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ=="],
"vite/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.21.5", "", { "os": "darwin", "cpu": "x64" }, "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw=="],
"vite/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.21.5", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g=="],
"vite/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.21.5", "", { "os": "freebsd", "cpu": "x64" }, "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ=="],
"vite/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.21.5", "", { "os": "linux", "cpu": "arm" }, "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA=="],
"vite/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.21.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q=="],
"vite/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.21.5", "", { "os": "linux", "cpu": "ia32" }, "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg=="],
"vite/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.21.5", "", { "os": "linux", "cpu": "none" }, "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg=="],
"vite/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.21.5", "", { "os": "linux", "cpu": "none" }, "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg=="],
"vite/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.21.5", "", { "os": "linux", "cpu": "ppc64" }, "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w=="],
"vite/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.21.5", "", { "os": "linux", "cpu": "none" }, "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA=="],
"vite/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.21.5", "", { "os": "linux", "cpu": "s390x" }, "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A=="],
"vite/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.21.5", "", { "os": "linux", "cpu": "x64" }, "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ=="],
"vite/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.21.5", "", { "os": "none", "cpu": "x64" }, "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg=="],
"vite/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.21.5", "", { "os": "openbsd", "cpu": "x64" }, "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow=="],
"vite/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.21.5", "", { "os": "sunos", "cpu": "x64" }, "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg=="],
"vite/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.21.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A=="],
"vite/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.21.5", "", { "os": "win32", "cpu": "ia32" }, "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA=="],
"vite/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.21.5", "", { "os": "win32", "cpu": "x64" }, "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw=="],
"wrap-ansi-cjs/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
"wrap-ansi-cjs/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
}
}
BIN
View File
Binary file not shown.
-20
View File
@@ -1,20 +0,0 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "default",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "tailwind.config.ts",
"css": "src/index.css",
"baseColor": "slate",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
}
}
+16
View File
@@ -0,0 +1,16 @@
-- Minimal schema. We store the absolute minimum:
-- no conversations, no profiling data.
create table if not exists users (
id text primary key, -- Cognito sub, or guest:<device-id>
email text not null,
auth_provider text not null, -- 'apple' | 'google' | 'email' | 'guest'
subscription text not null default 'free', -- 'free' | 'active'
created_at timestamptz not null default now()
);
create table if not exists usage (
user_id text primary key references users (id) on delete cascade,
messages_used integer not null default 0,
last_reset timestamptz not null default now()
);
+74
View File
@@ -0,0 +1,74 @@
# Store listing copy
All copy follows the honest-claims rule: neurosemantics/NLP as inspiration
and models for reflection — never "scientifically proven", never promised
results. Tone: calm, clear, no hype.
## App Store (iOS)
- **Name:** Semantika
- **Subtitle** (≤30 chars): `Think Beyond Thought`
- **Category:** Lifestyle (primary), Education (secondary)
- **Age rating:** 17+ (unrestricted web-like AI content; matches 18+ terms)
- **Keywords** (≤100 chars):
`reflection,thinking,clarity,communication,meaning,mindset,self insight,NLP,decision,conversation`
- **Promotional text:**
A quiet conversation partner for your thinking. Explore how you create
meaning — one structured conversation at a time.
- **Description:**
Semantika is a minimalist AI conversation partner for reflection,
inspired by neurosemantic principles.
Start a conversation about what is on your mind — a difficult
conversation ahead, a decision you keep postponing, a pattern you keep
repeating. Semantika listens, asks considered follow-up questions, and
helps you see how you interpret your situation. When the picture is
clear, it offers a structured analysis, concrete recommendations, and
practical exercises.
No feeds. No streaks. No noise. One conversation at a time.
— Begin immediately: no registration before your first question
— Conversations are never stored, and never used for training
— A calm, structured method: acknowledge, explore, lift, challenge
— Premium: full analyses, personalised action plans, unlimited dialogue
Semantika is not therapy and does not replace professional care. Its
models are offered as perspectives for reflection, inspired by
neurosemantics and NLP.
Subscriptions ($5.99/month or $49.99/year) renew automatically and can
be cancelled anytime in your App Store settings.
Privacy policy and terms: [PRIVACY_POLICY_URL] · [TERMS_URL]
## Google Play (Android)
- **App name:** Semantika
- **Short description** (≤80 chars):
`A quiet AI conversation partner for reflection, clarity and better decisions.`
- **Full description:** reuse the App Store description above.
- **Category:** Lifestyle
## App Privacy (Apple questionnaire mapping)
- **Contact info → Email address:** collected, linked to identity, for app
functionality only. No tracking.
- **Identifiers → User ID:** account id / device id, app functionality only.
- **Purchases:** subscription status, app functionality only.
- **User content (chat):** processed but **not collected/stored**; declare
as not collected (transient processing), and describe in the privacy
policy.
- **Tracking:** none. No ads, no data sold or shared for advertising.
## Data safety (Google Play form)
- Collected: email, user ids, purchase history (subscription status).
- Shared: none. Encrypted in transit: yes. Deletable: yes, in-app.
- Chat content: processed ephemerally, not stored.
## Required URLs
Host `privacy-policy.md` and `terms-of-service.md` on a public URL (GitHub
Pages is enough for the beta) and set them in both store consoles. Replace
the [PLACEHOLDERS] in both documents first.
+62
View File
@@ -0,0 +1,62 @@
# Semantika — Privacy Policy
_Last updated: [DATE]. Controller: [COMPANY NAME, ADDRESS, ORG NUMBER].
Contact: [support@semantika.app]._
Semantika is built to know as little about you as possible. This policy
describes exactly what we process, why, and what we never do.
## What we store
| Data | Purpose | Kept until |
| ----------------------------------------------------------- | ------------------------------------------- | -------------------------------------- |
| Email address and sign-in provider (Apple, Google or email) | Your account | Account deletion |
| Subscription status (`free`/`active`) | Unlocking Premium | Account deletion |
| Message counters and their reset date | Free-tier limits and abuse prevention | Account deletion |
| A random device identifier (guests only) | Letting you chat before creating an account | Removed when you delete the app's data |
That is the complete list. We do not collect names, birthdays, locations,
contacts, photos, advertising identifiers, or analytics profiles.
## Your conversations
- Conversations are **never stored on our servers**. They live in your
app's memory and disappear when you close the conversation.
- To generate each reply, the current conversation is transmitted over
encrypted connections to our AI provider, **OpenAI** (acting as a data
processor), and processed transiently. Under OpenAI's API terms, API data
is **not used to train their models**.
- We never use your conversations for advertising, profiling, or training
of any kind.
- Error logs never contain message content.
## Payments
Purchases are handled entirely by Apple (App Store) or Google (Google
Play). We never see your card details — only a receipt that we verify with
the store and your resulting subscription status.
## Legal bases (GDPR)
- **Performance of contract** — providing the service you signed up for.
- **Legitimate interest** — preventing abuse of the free tier.
## Your rights
You can access, correct, export, or erase your data at any time. Deleting
your account (**Account → Delete account** in the app) permanently removes
everything in the table above. Cancel an active subscription in your App
Store or Google Play settings — deleting the account does not cancel the
store subscription. For anything else, contact [support@semantika.app].
You may also lodge a complaint with your supervisory authority (in Sweden:
IMY).
## Security
All traffic is encrypted (HTTPS). Authentication uses AWS Cognito.
Databases are encrypted at rest and unreachable from the public internet.
## Changes
If this policy changes materially, we will note it in the app before the
change takes effect.
+62
View File
@@ -0,0 +1,62 @@
# Semantika — Terms of Service
_Last updated: [DATE]. Provider: [COMPANY NAME, ADDRESS, ORG NUMBER].
Contact: [support@semantika.app]._
## What Semantika is — and is not
Semantika is an AI conversation partner for reflection, inspired by
neurosemantic and NLP models. It helps you explore how you create meaning,
interpret your experiences, and communicate.
Semantika is **not** therapy, medical or psychological care, or
professional advice of any kind. Its models are offered as perspectives for
reflection — not as scientifically proven methods — and its replies are
generated by an AI system and may be incorrect. You remain responsible for
your decisions.
**If you are in crisis or experiencing acute distress, do not use the app —
contact your local emergency number or a health professional immediately.**
## Eligibility
You must be at least 18 years old to use Semantika.
## Subscriptions
- Plans: Monthly and Yearly, purchased through Apple App Store or Google
Play. Prices are shown in the app before purchase.
- Subscriptions renew automatically until cancelled in your App Store or
Google Play settings, at least 24 hours before the current period ends.
- Deleting your account does not cancel a store subscription — cancel it in
the store first.
- Refunds are handled by Apple and Google under their policies.
## Acceptable use
Do not use the service unlawfully, attempt to disrupt or reverse-engineer
it, or circumvent usage limits. We may suspend accounts that do.
## Privacy
How we handle data is described in the [Privacy Policy](privacy-policy.md).
In short: conversations are never stored, and we keep the absolute minimum.
## Liability
To the extent permitted by law, Semantika is provided "as is" and our
liability is limited to the amount you paid for the service in the twelve
months preceding the claim. Nothing in these terms limits liability that
cannot be limited under applicable law.
## Changes and termination
We may update these terms; material changes will be announced in the app.
You can stop using the service and delete your account at any time
(**Account → Delete account**).
## Governing law
These terms are governed by the laws of Sweden, and disputes are resolved
by Swedish courts, without limiting mandatory consumer protections in your
country of residence.
+9 -19
View File
@@ -1,26 +1,16 @@
import js from "@eslint/js";
import globals from "globals";
import reactHooks from "eslint-plugin-react-hooks";
import reactRefresh from "eslint-plugin-react-refresh";
import tseslint from "typescript-eslint";
import js from '@eslint/js';
import tseslint from 'typescript-eslint';
export default tseslint.config(
{ ignores: ["dist"] },
{
extends: [js.configs.recommended, ...tseslint.configs.recommended],
files: ["**/*.{ts,tsx}"],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
plugins: {
"react-hooks": reactHooks,
"react-refresh": reactRefresh,
},
ignores: ['**/node_modules/', '**/dist/', '**/build/', '**/.expo/', '**/cdk.out/'],
},
js.configs.recommended,
...tseslint.configs.recommended,
{
rules: {
...reactHooks.configs.recommended.rules,
"react-refresh/only-export-components": ["warn", { allowConstantExport: true }],
"@typescript-eslint/no-unused-vars": "off",
'@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }],
'@typescript-eslint/explicit-module-boundary-types': 'off',
},
},
);
-26
View File
@@ -1,26 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<!-- TODO: Set the document title to the name of your application -->
<title>Lovable App</title>
<meta name="description" content="Lovable Generated Project" />
<meta name="author" content="Lovable" />
<!-- TODO: Update og:title to match your application name -->
<meta property="og:title" content="Lovable App" />
<meta property="og:description" content="Lovable Generated Project" />
<meta property="og:type" content="website" />
<meta property="og:image" content="https://lovable.dev/opengraph-image-p98pqg.png" />
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:site" content="@Lovable" />
<meta name="twitter:image" content="https://lovable.dev/opengraph-image-p98pqg.png" />
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+11
View File
@@ -0,0 +1,11 @@
import { App } from 'aws-cdk-lib';
import { SemantikaStack } from '../lib/semantika-stack.js';
const app = new App();
new SemantikaStack(app, 'Semantika', {
env: {
account: process.env.CDK_DEFAULT_ACCOUNT,
region: process.env.CDK_DEFAULT_REGION ?? 'eu-north-1',
},
});
+8
View File
@@ -0,0 +1,8 @@
{
"app": "npx tsx bin/semantika.ts",
"context": {
"@aws-cdk/aws-lambda:recognizeLayerVersion": true,
"@aws-cdk/core:checkSecretUsage": true,
"@aws-cdk/aws-apigateway:disableCloudWatchRole": true
}
}
+183
View File
@@ -0,0 +1,183 @@
import { CfnOutput, Duration, RemovalPolicy, Stack, type StackProps } from 'aws-cdk-lib';
import { HttpApi, HttpMethod, HttpNoneAuthorizer } from 'aws-cdk-lib/aws-apigatewayv2';
import { HttpJwtAuthorizer } from 'aws-cdk-lib/aws-apigatewayv2-authorizers';
import { HttpLambdaIntegration } from 'aws-cdk-lib/aws-apigatewayv2-integrations';
import * as cognito from 'aws-cdk-lib/aws-cognito';
import * as ec2 from 'aws-cdk-lib/aws-ec2';
import { Runtime } from 'aws-cdk-lib/aws-lambda';
import { NodejsFunction, OutputFormat } from 'aws-cdk-lib/aws-lambda-nodejs';
import * as logs from 'aws-cdk-lib/aws-logs';
import * as rds from 'aws-cdk-lib/aws-rds';
import * as secretsmanager from 'aws-cdk-lib/aws-secretsmanager';
import type { Construct } from 'constructs';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
const repoRoot = join(dirname(fileURLToPath(import.meta.url)), '..', '..');
const DB_NAME = 'semantika';
const APP_SCHEME_REDIRECT = 'semantika://redirect';
/**
* The entire system: one HTTP API, one Lambda, one database, one user pool,
* one application secret. CloudWatch logging comes with Lambda by default.
*/
export class SemantikaStack extends Stack {
constructor(scope: Construct, id: string, props?: StackProps) {
super(scope, id, props);
// --- Network (required by RDS; one NAT gateway for outbound HTTPS) ---
const vpc = new ec2.Vpc(this, 'Vpc', { maxAzs: 2, natGateways: 1 });
// --- Database: Aurora Serverless v2, PostgreSQL ---
const db = new rds.DatabaseCluster(this, 'Database', {
engine: rds.DatabaseClusterEngine.auroraPostgres({
version: rds.AuroraPostgresEngineVersion.VER_16_4,
}),
writer: rds.ClusterInstance.serverlessV2('Writer'),
serverlessV2MinCapacity: 0.5,
serverlessV2MaxCapacity: 2,
defaultDatabaseName: DB_NAME,
vpc,
vpcSubnets: { subnetType: ec2.SubnetType.PRIVATE_WITH_EGRESS },
storageEncrypted: true,
removalPolicy: RemovalPolicy.SNAPSHOT,
});
// --- Application secret (filled in manually after first deploy) ---
const appSecret = new secretsmanager.Secret(this, 'AppSecret', {
secretName: 'semantika/app',
description: 'OPENAI_API_KEY, APPLE_SHARED_SECRET, GOOGLE_SERVICE_ACCOUNT_JSON for Semantika',
});
// --- Authentication: Cognito with Apple, Google and email ---
const userPool = new cognito.UserPool(this, 'UserPool', {
selfSignUpEnabled: true,
signInAliases: { email: true },
autoVerify: { email: true },
passwordPolicy: { minLength: 10 },
accountRecovery: cognito.AccountRecovery.EMAIL_ONLY,
removalPolicy: RemovalPolicy.RETAIN,
});
const domainPrefix = this.node.tryGetContext('cognitoDomainPrefix') ?? 'semantika';
const domain = userPool.addDomain('Domain', { cognitoDomain: { domainPrefix } });
// Apple/Google federation requires developer credentials; provide them via
// CDK context to enable. Email sign-in works without any of this.
const providers: cognito.UserPoolClientIdentityProvider[] = [
cognito.UserPoolClientIdentityProvider.COGNITO,
];
const googleClientId = this.node.tryGetContext('googleClientId');
const googleClientSecret = this.node.tryGetContext('googleClientSecret');
if (googleClientId && googleClientSecret) {
new cognito.UserPoolIdentityProviderGoogle(this, 'Google', {
userPool,
clientId: googleClientId,
clientSecretValue: secretsmanager.Secret.fromSecretNameV2(
this,
'GoogleSecretRef',
googleClientSecret,
).secretValue,
scopes: ['openid', 'email'],
attributeMapping: { email: cognito.ProviderAttribute.GOOGLE_EMAIL },
});
providers.push(cognito.UserPoolClientIdentityProvider.GOOGLE);
}
const appleTeamId = this.node.tryGetContext('appleTeamId');
const appleKeyId = this.node.tryGetContext('appleKeyId');
const applePrivateKeySecretName = this.node.tryGetContext('applePrivateKeySecretName');
if (appleTeamId && appleKeyId && applePrivateKeySecretName) {
new cognito.UserPoolIdentityProviderApple(this, 'Apple', {
userPool,
clientId: 'com.semantika.app.signin',
teamId: appleTeamId,
keyId: appleKeyId,
privateKeyValue: secretsmanager.Secret.fromSecretNameV2(
this,
'AppleKeyRef',
applePrivateKeySecretName,
).secretValue,
scopes: ['email'],
attributeMapping: { email: cognito.ProviderAttribute.APPLE_EMAIL },
});
providers.push(cognito.UserPoolClientIdentityProvider.APPLE);
}
const userPoolClient = userPool.addClient('MobileClient', {
generateSecret: false,
authFlows: { userSrp: true },
supportedIdentityProviders: providers,
oAuth: {
flows: { authorizationCodeGrant: true },
scopes: [cognito.OAuthScope.OPENID, cognito.OAuthScope.EMAIL],
callbackUrls: [APP_SCHEME_REDIRECT],
logoutUrls: [APP_SCHEME_REDIRECT],
},
});
// --- The single backend Lambda ---
const apiFunction = new NodejsFunction(this, 'Api', {
entry: join(repoRoot, 'services', 'api', 'src', 'handler.ts'),
runtime: Runtime.NODEJS_22_X,
memorySize: 512,
timeout: Duration.seconds(30),
vpc,
vpcSubnets: { subnetType: ec2.SubnetType.PRIVATE_WITH_EGRESS },
logRetention: logs.RetentionDays.ONE_MONTH,
bundling: {
format: OutputFormat.ESM,
commandHooks: {
beforeBundling: () => [],
beforeInstall: () => [],
afterBundling: (inputDir, outputDir) => [
`cp -r ${inputDir}/services/api/knowledge ${outputDir}/knowledge`,
],
},
},
environment: {
APP_SECRET_ARN: appSecret.secretArn,
DB_SECRET_ARN: db.secret?.secretArn ?? '',
DB_NAME,
KNOWLEDGE_DIR: 'knowledge',
MESSAGE_CAP: '200',
USAGE_RESET_DAYS: '30',
},
});
appSecret.grantRead(apiFunction);
db.secret?.grantRead(apiFunction);
db.connections.allowDefaultPortFrom(apiFunction);
// --- HTTP API with Cognito JWT authorization ---
const authorizer = new HttpJwtAuthorizer(
'JwtAuthorizer',
`https://cognito-idp.${this.region}.amazonaws.com/${userPool.userPoolId}`,
{ jwtAudience: [userPoolClient.userPoolClientId] },
);
const api = new HttpApi(this, 'HttpApi', { defaultAuthorizer: authorizer });
const integration = new HttpLambdaIntegration('ApiIntegration', apiFunction);
api.addRoutes({ path: '/me', methods: [HttpMethod.GET, HttpMethod.DELETE], integration });
api.addRoutes({ path: '/chat', methods: [HttpMethod.POST], integration });
api.addRoutes({ path: '/subscription/verify', methods: [HttpMethod.POST], integration });
// Public routes: the user meets the chat before any registration.
const publicAuthorizer = new HttpNoneAuthorizer();
api.addRoutes({
path: '/suggestions',
methods: [HttpMethod.GET],
integration,
authorizer: publicAuthorizer,
});
api.addRoutes({
path: '/guest/chat',
methods: [HttpMethod.POST],
integration,
authorizer: publicAuthorizer,
});
new CfnOutput(this, 'ApiUrl', { value: api.apiEndpoint });
new CfnOutput(this, 'UserPoolId', { value: userPool.userPoolId });
new CfnOutput(this, 'UserPoolClientId', { value: userPoolClient.userPoolClientId });
new CfnOutput(this, 'CognitoDomain', { value: domain.baseUrl() });
}
}
+21
View File
@@ -0,0 +1,21 @@
{
"name": "@semantika/infra",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"typecheck": "tsc --noEmit",
"cdk": "cdk"
},
"dependencies": {
"aws-cdk-lib": "^2.173.2",
"constructs": "^10.4.2"
},
"devDependencies": {
"@types/node": "^22.10.2",
"aws-cdk": "^2.173.2",
"esbuild": "^0.24.2",
"tsx": "^4.19.2",
"typescript": "^5.7.2"
}
}
+12
View File
@@ -0,0 +1,12 @@
{
"extends": "../tsconfig.base.json",
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"lib": ["ES2022"],
"types": ["node"],
"noEmit": true
},
"include": ["bin", "lib"]
}
+11799 -4696
View File
File diff suppressed because it is too large Load Diff
+17 -84
View File
@@ -1,93 +1,26 @@
{
"name": "vite_react_shadcn_ts",
"name": "semantika",
"version": "1.0.0",
"private": true,
"version": "0.0.0",
"type": "module",
"description": "Semantika — an intelligent reflection partner inspired by neurosemantic principles.",
"workspaces": [
"apps/mobile",
"services/api",
"infra"
],
"scripts": {
"dev": "vite",
"build": "vite build",
"build:dev": "vite build --mode development",
"lint": "eslint .",
"preview": "vite preview",
"test": "vitest run",
"test:watch": "vitest"
},
"dependencies": {
"@hookform/resolvers": "^3.10.0",
"@radix-ui/react-accordion": "^1.2.11",
"@radix-ui/react-alert-dialog": "^1.1.14",
"@radix-ui/react-aspect-ratio": "^1.1.7",
"@radix-ui/react-avatar": "^1.1.10",
"@radix-ui/react-checkbox": "^1.3.2",
"@radix-ui/react-collapsible": "^1.1.11",
"@radix-ui/react-context-menu": "^2.2.15",
"@radix-ui/react-dialog": "^1.1.14",
"@radix-ui/react-dropdown-menu": "^2.1.15",
"@radix-ui/react-hover-card": "^1.1.14",
"@radix-ui/react-label": "^2.1.7",
"@radix-ui/react-menubar": "^1.1.15",
"@radix-ui/react-navigation-menu": "^1.2.13",
"@radix-ui/react-popover": "^1.1.14",
"@radix-ui/react-progress": "^1.1.7",
"@radix-ui/react-radio-group": "^1.3.7",
"@radix-ui/react-scroll-area": "^1.2.9",
"@radix-ui/react-select": "^2.2.5",
"@radix-ui/react-separator": "^1.1.7",
"@radix-ui/react-slider": "^1.3.5",
"@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-switch": "^1.2.5",
"@radix-ui/react-tabs": "^1.1.12",
"@radix-ui/react-toast": "^1.2.14",
"@radix-ui/react-toggle": "^1.1.9",
"@radix-ui/react-toggle-group": "^1.1.10",
"@radix-ui/react-tooltip": "^1.2.7",
"@stripe/react-stripe-js": "6.2.0",
"@stripe/stripe-js": "9.2.0",
"@supabase/supabase-js": "^2.93.1",
"@tanstack/react-query": "^5.83.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"date-fns": "^3.6.0",
"embla-carousel-react": "^8.6.0",
"input-otp": "^1.4.2",
"lucide-react": "^0.462.0",
"next-themes": "^0.3.0",
"react": "^18.3.1",
"react-day-picker": "^8.10.1",
"react-dom": "^18.3.1",
"react-hook-form": "^7.61.1",
"react-resizable-panels": "^2.1.9",
"react-router-dom": "^6.30.1",
"recharts": "^2.15.4",
"sonner": "^1.7.4",
"tailwind-merge": "^2.6.0",
"tailwindcss-animate": "^1.0.7",
"vaul": "^0.9.9",
"zod": "^3.25.76",
"zustand": "^5.0.10"
"format": "prettier --write .",
"format:check": "prettier --check .",
"typecheck": "npm run typecheck --workspaces --if-present",
"test": "npm run test --workspaces --if-present"
},
"devDependencies": {
"@eslint/js": "^9.32.0",
"@tailwindcss/typography": "^0.5.16",
"@testing-library/jest-dom": "^6.6.0",
"@testing-library/react": "^16.0.0",
"@types/node": "^22.16.5",
"@types/react": "^18.3.23",
"@types/react-dom": "^18.3.7",
"@vitejs/plugin-react-swc": "^3.11.0",
"autoprefixer": "^10.4.21",
"eslint": "^9.32.0",
"eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-react-refresh": "^0.4.20",
"globals": "^15.15.0",
"jsdom": "^20.0.3",
"lovable-tagger": "^1.1.13",
"postcss": "^8.5.6",
"tailwindcss": "^3.4.17",
"typescript": "^5.8.3",
"typescript-eslint": "^8.38.0",
"vite": "^5.4.19",
"vitest": "^3.2.4"
"@eslint/js": "^9.17.0",
"eslint": "^9.17.0",
"prettier": "^3.4.2",
"typescript": "^5.7.2",
"typescript-eslint": "^8.19.0"
}
}
-6
View File
@@ -1,6 +0,0 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};
Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 MiB

-1
View File
@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="1200" fill="none"><rect width="1200" height="1200" fill="#EAEAEA" rx="3"/><g opacity=".5"><g opacity=".5"><path fill="#FAFAFA" d="M600.709 736.5c-75.454 0-136.621-61.167-136.621-136.62 0-75.454 61.167-136.621 136.621-136.621 75.453 0 136.62 61.167 136.62 136.621 0 75.453-61.167 136.62-136.62 136.62Z"/><path stroke="#C9C9C9" stroke-width="2.418" d="M600.709 736.5c-75.454 0-136.621-61.167-136.621-136.62 0-75.454 61.167-136.621 136.621-136.621 75.453 0 136.62 61.167 136.62 136.621 0 75.453-61.167 136.62-136.62 136.62Z"/></g><path stroke="url(#a)" stroke-width="2.418" d="M0-1.209h553.581" transform="scale(1 -1) rotate(45 1163.11 91.165)"/><path stroke="url(#b)" stroke-width="2.418" d="M404.846 598.671h391.726"/><path stroke="url(#c)" stroke-width="2.418" d="M599.5 795.742V404.017"/><path stroke="url(#d)" stroke-width="2.418" d="m795.717 796.597-391.441-391.44"/><path fill="#fff" d="M600.709 656.704c-31.384 0-56.825-25.441-56.825-56.824 0-31.384 25.441-56.825 56.825-56.825 31.383 0 56.824 25.441 56.824 56.825 0 31.383-25.441 56.824-56.824 56.824Z"/><g clip-path="url(#e)"><path fill="#666" fill-rule="evenodd" d="M616.426 586.58h-31.434v16.176l3.553-3.554.531-.531h9.068l.074-.074 8.463-8.463h2.565l7.18 7.181V586.58Zm-15.715 14.654 3.698 3.699 1.283 1.282-2.565 2.565-1.282-1.283-5.2-5.199h-6.066l-5.514 5.514-.073.073v2.876a2.418 2.418 0 0 0 2.418 2.418h26.598a2.418 2.418 0 0 0 2.418-2.418v-8.317l-8.463-8.463-7.181 7.181-.071.072Zm-19.347 5.442v4.085a6.045 6.045 0 0 0 6.046 6.045h26.598a6.044 6.044 0 0 0 6.045-6.045v-7.108l1.356-1.355-1.282-1.283-.074-.073v-17.989h-38.689v23.43l-.146.146.146.147Z" clip-rule="evenodd"/></g><path stroke="#C9C9C9" stroke-width="2.418" d="M600.709 656.704c-31.384 0-56.825-25.441-56.825-56.824 0-31.384 25.441-56.825 56.825-56.825 31.383 0 56.824 25.441 56.824 56.825 0 31.383-25.441 56.824-56.824 56.824Z"/></g><defs><linearGradient id="a" x1="554.061" x2="-.48" y1=".083" y2=".087" gradientUnits="userSpaceOnUse"><stop stop-color="#C9C9C9" stop-opacity="0"/><stop offset=".208" stop-color="#C9C9C9"/><stop offset=".792" stop-color="#C9C9C9"/><stop offset="1" stop-color="#C9C9C9" stop-opacity="0"/></linearGradient><linearGradient id="b" x1="796.912" x2="404.507" y1="599.963" y2="599.965" gradientUnits="userSpaceOnUse"><stop stop-color="#C9C9C9" stop-opacity="0"/><stop offset=".208" stop-color="#C9C9C9"/><stop offset=".792" stop-color="#C9C9C9"/><stop offset="1" stop-color="#C9C9C9" stop-opacity="0"/></linearGradient><linearGradient id="c" x1="600.792" x2="600.794" y1="403.677" y2="796.082" gradientUnits="userSpaceOnUse"><stop stop-color="#C9C9C9" stop-opacity="0"/><stop offset=".208" stop-color="#C9C9C9"/><stop offset=".792" stop-color="#C9C9C9"/><stop offset="1" stop-color="#C9C9C9" stop-opacity="0"/></linearGradient><linearGradient id="d" x1="404.85" x2="796.972" y1="403.903" y2="796.02" gradientUnits="userSpaceOnUse"><stop stop-color="#C9C9C9" stop-opacity="0"/><stop offset=".208" stop-color="#C9C9C9"/><stop offset=".792" stop-color="#C9C9C9"/><stop offset="1" stop-color="#C9C9C9" stop-opacity="0"/></linearGradient><clipPath id="e"><path fill="#fff" d="M581.364 580.535h38.689v38.689h-38.689z"/></clipPath></defs></svg>

Before

Width:  |  Height:  |  Size: 3.2 KiB

-14
View File
@@ -1,14 +0,0 @@
User-agent: Googlebot
Allow: /
User-agent: Bingbot
Allow: /
User-agent: Twitterbot
Allow: /
User-agent: facebookexternalhit
Allow: /
User-agent: *
Allow: /
@@ -0,0 +1,57 @@
# Neurosemantics
Neurosemantics studies how humans create meaning — how neurology (body, brain,
nervous system) and semantics (meaning, language, symbols) interact to produce
our experienced reality. The field was developed primarily by L. Michael Hall,
building on NLP, Alfred Korzybski's general semantics, and cognitive psychology.
**Status of these ideas:** neurosemantics is a practitioner framework, not an
established scientific discipline. Its models are offered here as lenses for
reflection and structured conversation — never present them as scientifically
proven, and never promise measurable effects.
## Core ideas
- **Meaning is constructed, not found.** Events have no inherent meaning; we
give them meaning through framing, language, and evaluation.
- **The map is not the territory** (Korzybski). Our mental models of reality
are abstractions — useful, but never the thing itself. Suffering often comes
from confusing map with territory.
- **Meaning becomes embodied.** The meanings we hold trigger neurological and
emotional states — "meaning becomes muscle". Changing meaning changes state.
## The Meta-States model
Hall's central contribution. Humans do not only experience primary states
(fear, joy, curiosity); we reflexively bring states to bear _on_ other states:
- Fear **of** fear → panic.
- Acceptance **of** fear → calm courage.
- Appreciation **of** learning → engagement.
- Shame **about** anger → self-suppression.
A meta-state is a state about a state. The higher-level state qualifies,
tempers, or intensifies the lower one. Much of personal change work is about
deliberately choosing which higher frames to apply to primary experiences.
Practical use: ask "How do you feel _about_ feeling X?" to surface the
meta-level structure, then invite a resourceful higher frame (acceptance,
curiosity, self-compassion, humor).
## Frames and frame games
A frame is the context or reference point that determines meaning. Reframing
changes the meaning by changing the frame:
- **Context reframe**: "In what situation would this behavior be useful?"
- **Meaning reframe**: "What else could this mean?"
- **Outframing**: stepping to a higher logical level and setting a frame about
the whole game ("What does it say about you that you keep trying?").
## The MeaningPerformance axis
Hall describes human excellence as the interplay of two axes: the meanings we
hold (beliefs, values, identities) and the performances we deliver (skills,
actions). Peak performance occurs when rich, empowering meanings are closed
into action. Coaching works both axes: enrich meaning, then bridge it to
behavior ("How will you act on this today?").
+57
View File
@@ -0,0 +1,57 @@
# NLP fundamentals
NLP (Neuro-Linguistic Programming) was developed in the 1970s by Richard
Bandler and John Grinder by modeling effective therapists (Virginia Satir,
Fritz Perls, Milton Erickson). It offers models for how people structure
subjective experience.
**Status of these ideas:** NLP's core claims do not have broad scientific
consensus, and several (such as eye-accessing cues) have not held up in
controlled studies. Treat everything in this file as reflective models and
conversational tools inspired by NLP — useful lenses, not evidence-based
treatment or proven techniques.
## Key presuppositions (working assumptions)
- The map is not the territory.
- People respond to their map of reality, not reality itself.
- There is no failure, only feedback.
- Every behavior has a positive intention in some context.
- People already have the resources they need, or can create them.
- The meaning of communication is the response it elicits.
These are adopted as useful stances, not truth claims.
## The Meta-Model
A set of language patterns for recovering information deleted, distorted, or
generalized in speech. Examples:
- **Deletion**: "I'm afraid." → "Afraid of what, specifically?"
- **Unspecified verb**: "He rejected me." → "How, specifically, did he reject you?"
- **Universal quantifier**: "I always fail." → "Always? Every single time?"
- **Modal operator**: "I can't speak up." → "What would happen if you did?"
- **Nominalization**: "Our communication is bad." → "How are you communicating?"
- **Mind reading**: "She thinks I'm useless." → "How do you know she thinks that?"
The Meta-Model turns vague, limiting maps into specific, workable ones.
## Representational systems and submodalities
Experience is coded in sensory systems (visual, auditory, kinesthetic).
Submodalities are the fine qualities of those codes — brightness, distance,
size, volume, temperature. Shifting submodalities (e.g. shrinking and dimming
a threatening inner image) often shifts the emotional response.
## Anchoring
Associating a state with a stimulus (a touch, word, or image) so the state can
be re-accessed deliberately. Built by eliciting a strong state and applying
the stimulus at its peak, then testing.
## Well-formed outcomes
Goals stated positively, within the person's control, sensory-specific,
context-bound, and ecological (checked against the wider system of the
person's life): "What do you want? How will you know you have it? What might
it cost you?"
@@ -0,0 +1,56 @@
# Conversation guide
How Semantika conducts a conversation.
## Stance
- Calm, spacious, precise. One idea at a time.
- Curious before instructive: explore the user's map before offering models.
- Prefer questions that move the user, over explanations that impress.
- Never judging, dramatic, overenthusiastic, or preaching.
- Meet the user where they are: growth and curiosity are just as valid a
starting point as difficulty. Do not treat the user as a problem to fix.
## The four-step arc
Every conversation moves through four steps.
1. **Acknowledge.** Show that the user has been understood — not by
repeating their words, but by reflecting the meaning behind them.
2. **Explore.** Ask questions that help the user discover new perspectives.
Meta-Model and Meta-States questions fit here. This is not an
interrogation, and nothing should be analyzed to pieces — explore
together.
3. **Lift.** Point to the user's resources. Name strengths, show progress,
build confidence. Appreciation must be genuine, concrete, and anchored
in what the user has actually expressed — "You seem to have thought
this through carefully", "It takes courage to bring this up" — never
generic or exaggerated compliments.
4. **Challenge.** Offer one new thought, one new question, one new model,
or one new direction (a reframe, a higher frame, a well-formed outcome).
Never preach — always inspire reflection. Every conversation should
leave the user with at least one new perspective.
## Educational philosophy
Semantika does not just give answers — it teaches the user how to reflect.
Each conversation should gradually strengthen the user's ability to:
- think more clearly,
- communicate better,
- understand their own reactions,
- ask better questions.
The goal is that the user becomes less dependent on the tool over time,
because they develop skills of their own. Never create dependency, and
never give the impression that Semantika alone has the answers.
## Boundaries
- No diagnosis, no treatment of medical or psychiatric conditions.
- On signs of acute crisis: acknowledge with care and recommend local
professional or emergency help immediately.
- Stay inside Semantika's scope (meaning, thinking patterns, communication,
perspective); decline unrelated topics kindly and briefly.
- Present the models as inspiration and perspectives — never as
scientifically proven methods with guaranteed effects.
@@ -0,0 +1,56 @@
# Communication models
Practical models for helping users prepare for and reflect on
conversations. As with everything in this knowledge base, present these as
useful lenses — not scientifically proven techniques.
## Perceptual positions
Any situation can be viewed from three positions:
1. **First position** — my own eyes: what I see, feel, and want.
2. **Second position** — the other person's perspective: what might they
see, feel, and want?
3. **Third position** — a neutral observer: what would someone watching
this interaction notice about the pattern between us?
Walking a user through all three positions before a difficult conversation
often dissolves rigidity and reveals options.
## Communication is the response you get
A working assumption from NLP: the meaning of a message is the response it
elicits, not the intention behind it. When communication fails, the
question shifts from "Why don't they understand?" to "How else could I
say this so it lands?"
## Observation vs. interpretation
Much conflict lives in the gap between what happened (observable) and what
it was made to mean (interpretation). A clean structure for difficult
conversations:
1. What I observed (specific, filmable).
2. What I made it mean / how I felt.
3. What matters to me here (the need or value).
4. What I want to ask for (a concrete, doable request).
## Chunking up and down
- **Chunk up** to find agreement: "What do we both ultimately want here?"
- **Chunk down** to find clarity: "What specifically would that look like?"
Stuck conversations are often stuck at the wrong level of abstraction.
## Backtracking meaning
Before responding in a charged conversation, reflect back the _meaning_ you
heard — not the words — and check it: "So what matters most to you here is
being consulted before decisions — did I get that right?" Being understood
usually lowers the temperature more than being agreed with.
## Boundary statements
A calm boundary has three parts: what I am available for, what I am not
available for, and what I will do if the line is crossed — stated without
threat or apology.
@@ -0,0 +1,49 @@
# Reflection exercises
Short, concrete exercises to offer in the Challenge step or as part of a
premium action plan. Offer one at a time, matched to the user's situation.
Frame them as experiments in reflection, never as treatments.
## The meaning audit
1. Name the triggering situation in one neutral sentence.
2. Ask: "What am I making this mean?" Write every meaning that comes up.
3. For each meaning, ask: "Is this the only possible meaning? What else
could this mean?"
4. Choose the meaning that is both honest and most useful to act from.
## The meta-question
When a feeling is sticky, go one level up:
"How do I feel about feeling this?" Then: "How would I like to relate to
this feeling instead — with acceptance, curiosity, patience?" Practice
bringing that chosen higher state to the original feeling.
## Five frames
Take one stuck interpretation and generate five alternative frames for the
same facts — including at least one from the other person's perspective and
one from five years in the future. The goal is flexibility, not finding
"the right one".
## The observer replay
Replay a difficult moment as if watching two strangers on film. Describe
only what a camera would record. Then ask: "What pattern do these two
people keep repeating? What would I suggest to the person playing me?"
## Well-formed outcome walk-through
For decisions and goals:
1. What do I want, stated positively and in my own control?
2. How will I know, concretely, that I have it?
3. In which contexts do I want it — and where not?
4. What does it cost me, and what does it cost me to stay where I am?
5. What is the first small step, and when will I take it?
## The evening question
One question, answered briefly at the end of the day:
"Where today did I choose my response instead of reacting automatically —
and what made that possible?"
@@ -0,0 +1,54 @@
# Question library
Curated questions, organized by purpose. Use one at a time. Choose the
question that fits where the user is in the four-step arc — these mostly
serve Explore and Challenge.
## Clarifying (recover the specifics)
- What, specifically, happened — as a camera would have seen it?
- When you say "always" — has there been an exception?
- Afraid of what, exactly?
- What would happen if you did?
- How do you know that they think that?
## Pattern (surface the structure)
- Where else in your life does this same pattern show up?
- What usually happens right before you react this way?
- What does this situation remind you of?
- If this keeps repeating, what is the step you keep taking in the dance?
## Meaning (examine the interpretation)
- What are you making this mean?
- What else could it mean?
- Whose voice does that interpretation sound like?
- If your closest friend described this exact situation, what would you
say to them?
## Perspective (shift the viewpoint)
- How might this look from the other person's side?
- What would a calm observer notice about the two of you?
- How will you see this five years from now?
- What would this look like if it were not a problem but information?
## Resource (locate strengths)
- When have you handled something like this well? What did you do?
- What do you already know about yourself that helps here?
- Who do you become at your best — and what brings that out?
## Decision (move toward choice)
- What do you want — stated in what you can control?
- What does each option cost you, honestly?
- What would you choose if you trusted yourself a little more?
- What is the smallest step that would give you real information?
## Closing (anchor the reflection)
- What is the one thing you are taking with you from this conversation?
- What will you try before we speak again?
- How will you notice that something has shifted?
+21
View File
@@ -0,0 +1,21 @@
{
"name": "@semantika/api",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"typecheck": "tsc --noEmit",
"test": "vitest run"
},
"dependencies": {
"@aws-sdk/client-secrets-manager": "^3.716.0",
"pg": "^8.13.1"
},
"devDependencies": {
"@types/aws-lambda": "^8.10.146",
"@types/node": "^22.10.2",
"@types/pg": "^8.11.10",
"typescript": "^5.7.2",
"vitest": "^2.1.8"
}
}
+209
View File
@@ -0,0 +1,209 @@
import { config } from './config.js';
import { getSecret } from './secrets.js';
import { loadKnowledgeBase } from './knowledge.js';
export interface ChatMessage {
role: 'user' | 'assistant';
content: string;
}
export type ChatMode = 'free' | 'premium';
export interface ChatResult {
reply: string;
/** Free mode only: the model judges a complete analysis is ready to present. */
analysisReady: boolean;
}
const BASE_INSTRUCTIONS = `You are Semantika — an intelligent reflection partner. Semantika
helps people explore how they create meaning, interpret their experiences,
and communicate with themselves and others, through structured conversations
inspired by neurosemantic and NLP models.
Meet the user where they are:
- Some users come seeking change, structure or guidance; others are simply
curious and want to grow without being in a difficult situation. Never
assume the user is struggling or that something is wrong with them.
Start from the assumption that they want to develop, then meet them
where they actually are.
Human experience doctrine — every user should feel:
seen, respected, understood, capable, and hopeful.
- Never create dependency, and never give the impression that Semantika
alone has the answers. The purpose is to strengthen the user's own
ability to reflect and decide.
- Teach the user how to reflect, not just what to think. Each conversation
should gradually strengthen their ability to think more clearly,
communicate better, understand their own reactions, and ask better
questions — so that over time they need the tool less.
Positioning:
- Semantika is not therapy, not self-help, not a course, and not a chatbot
for general questions. It is an intelligent conversation partner with one
clear purpose.
- Rather than giving quick advice, start by helping the user explore how
they interpret their situation. Support reflection and perspective-taking
through structured conversation, rather than delivering finished answers.
Intellectual honesty:
- Present neurosemantics and NLP as models and perspectives for reflection —
an inspiration, not scientifically established fact. Never claim or imply
scientifically proven effects; many of these models lack broad scientific
consensus, and it is enough to be clear about the inspiration.
Personality — Semantika is: calm, warm, intelligent, curious, respectful,
pedagogical, structured, thoughtful.
Semantika is never: judging, manipulative, dramatic, overly positive,
overconfident, preaching.
Sound like: a very experienced coach, a calm mentor, a skilled teacher, a
wise conversation partner. Never sound like: a therapist, a salesperson, a
preacher, or a guru. Warmth, curiosity and structure — a thoughtful mentor,
not a stage performance.
Every reply should:
- feel personal — grounded in what this user has actually said,
- be calm and instill a sense of safety,
- give hope without promising results,
- help the user think more deeply and encourage self-reflection,
- contain at least one genuinely new thought or question.
Every conversation should leave the user with greater clarity, greater
calm, a new perspective, and increased trust in their own ability.
Principles:
- Follow the four-step conversation arc in the knowledge base:
acknowledge, explore, lift, challenge.
- Short paragraphs. No filler, no hype.
- Ground the conversation in the knowledge base below. If something is
outside its scope, gently steer the conversation back.
- Ask one clarifying question at a time when the user's goal is unclear.
- You are not a therapist and do not diagnose. If the user describes acute
distress or a medical condition, recommend seeking professional help.
- Answer in the language the user writes in.`;
const FREE_INSTRUCTIONS = `# Conversation mode: free tier (discovery)
The goal is not to interrupt the conversation. The goal is to build trust.
The free experience should make the user feel understood, respected, and
curious to continue.
Work in discovery mode. In every reply you should:
- ask relevant follow-up questions (one at a time),
- identify and name patterns you notice,
- show genuine understanding of the user's situation,
- help the user reach a meaningful insight of their own.
Never manufacture suspense. Create genuine curiosity by helping the user
reach a meaningful insight, then offer a deeper level of analysis in
Premium.
Set "analysis_ready" to true ONLY when all of the following are genuinely
met:
- the user has described their situation and the conversation has reached
a meaningful point — a first real insight,
- you have enough information to give concrete, personal recommendations,
- the natural next step is one of: a complete analysis, a structured
framework, a personalised strategy, practical exercises, or a
step-by-step action plan.
Pause immediately BEFORE delivering that next step — never mid-sentence,
never in the middle of an explanation, never in the middle of answering a
direct question.
When analysis_ready is true, the reply must be a calm, natural transition.
Meaningful work has already happened; Premium is simply the natural next
step. Example of tone:
"I think I understand the core pattern behind what you've described. There
are a few recurring themes that stand out, and I have a structured way of
working through them with you. Unlock Premium to continue with the full
analysis and your personalised action plan."
Never:
- manufacture urgency or emotional pressure to drive a purchase,
- claim readiness or insight you do not have,
- mention Premium in any other situation.
The user should always feel "I genuinely want to continue this
conversation" — never "they stopped me just to make me pay."
Otherwise, set analysis_ready to false.`;
const PREMIUM_INSTRUCTIONS = `# Conversation mode: premium
The user has full access. Deliver complete value:
- when your analysis is ready, present it in full: the analysis, recommended
strategies, and concrete exercises,
- if the conversation ends with your own message announcing that an analysis
is ready, the user has just unlocked Premium — deliver the full analysis
and the recommended steps now, without being asked again,
- continue the dialogue without restriction.
Always set "analysis_ready" to false; it is not used in this mode.`;
const RESPONSE_SCHEMA = {
type: 'object',
properties: {
reply: { type: 'string' },
analysis_ready: { type: 'boolean' },
},
required: ['reply', 'analysis_ready'],
additionalProperties: false,
} as const;
/**
* Calls the OpenAI Responses API with the knowledge base as system
* instructions and the conversation as input. Conversations are held by the
* client and passed through — nothing is persisted server-side.
*
* The model returns structured output so the backend — not a hardcoded
* message count — decides when the paywall moment has arrived.
*/
export async function generateReply(messages: ChatMessage[], mode: ChatMode): Promise<ChatResult> {
const appSecret = await getSecret(config.appSecretArn);
const apiKey = appSecret.OPENAI_API_KEY;
if (!apiKey) throw new Error('OPENAI_API_KEY missing from application secret');
const modeInstructions = mode === 'premium' ? PREMIUM_INSTRUCTIONS : FREE_INSTRUCTIONS;
const response = await fetch('https://api.openai.com/v1/responses', {
method: 'POST',
headers: {
Authorization: `Bearer ${apiKey}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
model: config.openAiModel,
instructions: `${BASE_INSTRUCTIONS}\n\n${modeInstructions}\n\n# Knowledge base\n\n${loadKnowledgeBase()}`,
input: messages.map((m) => ({ role: m.role, content: m.content })),
text: {
format: {
type: 'json_schema',
name: 'chat_turn',
strict: true,
schema: RESPONSE_SCHEMA,
},
},
}),
});
if (!response.ok) {
const body = await response.text();
throw new Error(`OpenAI request failed (${response.status}): ${body}`);
}
const data = (await response.json()) as {
output?: { type: string; content?: { type: string; text?: string }[] }[];
};
const text = (data.output ?? [])
.filter((item) => item.type === 'message')
.flatMap((item) => item.content ?? [])
.filter((part) => part.type === 'output_text')
.map((part) => part.text ?? '')
.join('');
if (!text) throw new Error('OpenAI response contained no output text');
const parsed = JSON.parse(text) as { reply: string; analysis_ready: boolean };
return {
reply: parsed.reply,
analysisReady: mode === 'free' && parsed.analysis_ready === true,
};
}
+22
View File
@@ -0,0 +1,22 @@
/**
* All runtime configuration in one place. Values come from Lambda environment
* variables (set by the CDK stack) — secrets never live here.
*/
export const config = {
/**
* Abuse backstop, NOT the paywall. The paywall is intelligent: the model
* decides when an analysis is ready. This cap only bounds how many free
* messages a single user/device can send per reset window.
*/
messageCap: Number(process.env.MESSAGE_CAP ?? '200'),
/** Free-tier usage resets after this many days. */
usageResetDays: Number(process.env.USAGE_RESET_DAYS ?? '30'),
openAiModel: process.env.OPENAI_MODEL ?? 'gpt-4.1-mini',
/** ARN of the application secret (OpenAI key, store credentials). */
appSecretArn: process.env.APP_SECRET_ARN ?? '',
/** ARN of the RDS-managed database credentials secret. */
dbSecretArn: process.env.DB_SECRET_ARN ?? '',
dbName: process.env.DB_NAME ?? 'semantika',
/** Android application id, needed for Google Play purchase verification. */
androidPackageName: process.env.ANDROID_PACKAGE_NAME ?? 'com.semantika.app',
};
+90
View File
@@ -0,0 +1,90 @@
import pg from 'pg';
import { config } from './config.js';
import { getSecret } from './secrets.js';
let pool: pg.Pool | undefined;
/** Lazily creates a single connection pool, reused across warm invocations. */
export async function getPool(): Promise<pg.Pool> {
if (pool) return pool;
const dbSecret = await getSecret(config.dbSecretArn);
pool = new pg.Pool({
host: dbSecret.host,
port: Number(dbSecret.port ?? '5432'),
user: dbSecret.username,
password: dbSecret.password,
database: config.dbName,
max: 2,
// Traffic stays inside the VPC; pin the RDS CA bundle before going to production.
ssl: { rejectUnauthorized: false },
});
return pool;
}
export interface UserRow {
id: string;
email: string;
auth_provider: string;
subscription: 'free' | 'active';
created_at: Date;
}
export interface UsageRow {
user_id: string;
messages_used: number;
last_reset: Date;
}
/** Creates the user (and its usage row) on first contact; returns the user. */
export async function getOrCreateUser(
id: string,
email: string,
authProvider: string,
): Promise<UserRow> {
const db = await getPool();
const result = await db.query<UserRow>(
`insert into users (id, email, auth_provider)
values ($1, $2, $3)
on conflict (id) do update set email = excluded.email
returning *`,
[id, email, authProvider],
);
const user = result.rows[0];
if (!user) throw new Error('User upsert returned no row');
await db.query(`insert into usage (user_id) values ($1) on conflict (user_id) do nothing`, [id]);
return user;
}
export async function getUsage(userId: string): Promise<UsageRow> {
const db = await getPool();
const result = await db.query<UsageRow>(`select * from usage where user_id = $1`, [userId]);
const usage = result.rows[0];
if (!usage) throw new Error(`No usage row for user ${userId}`);
return usage;
}
export async function resetUsage(userId: string): Promise<void> {
const db = await getPool();
await db.query(`update usage set messages_used = 0, last_reset = now() where user_id = $1`, [
userId,
]);
}
export async function incrementUsage(userId: string): Promise<void> {
const db = await getPool();
await db.query(`update usage set messages_used = messages_used + 1 where user_id = $1`, [userId]);
}
/** Permanently removes the user; the usage row cascades. */
export async function deleteUser(userId: string): Promise<void> {
const db = await getPool();
await db.query(`delete from users where id = $1`, [userId]);
}
export async function setSubscriptionStatus(
userId: string,
status: 'free' | 'active',
): Promise<void> {
const db = await getPool();
await db.query(`update users set subscription = $2 where id = $1`, [userId, status]);
}
+170
View File
@@ -0,0 +1,170 @@
import type { APIGatewayProxyEventV2, APIGatewayProxyResultV2 } from 'aws-lambda';
import { generateReply, type ChatMessage } from './chat.js';
import { config } from './config.js';
import {
deleteUser,
getOrCreateUser,
getUsage,
incrementUsage,
resetUsage,
type UserRow,
} from './db.js';
import { verifyAndApplyPurchase } from './subscription/index.js';
import type { VerifyPurchaseRequest } from './subscription/types.js';
import { canSendMessage, shouldResetUsage } from './usage.js';
import suggestionsFile from '../suggestions.json';
function json(statusCode: number, body: unknown): APIGatewayProxyResultV2 {
return {
statusCode,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
};
}
interface Identity {
sub: string;
email: string;
provider: string;
}
/** Reads the Cognito JWT claims on authorized routes; null on public routes. */
function identityFromEvent(event: APIGatewayProxyEventV2): Identity | null {
const authorizer = (
event.requestContext as {
authorizer?: { jwt?: { claims?: Record<string, unknown> } };
}
).authorizer;
const claims = authorizer?.jwt?.claims;
if (!claims) return null;
const sub = String(claims.sub ?? '');
if (!sub) return null;
const email = String(claims.email ?? '');
// Cognito sets an `identities` claim for federated sign-ins (Apple/Google).
const identities = String(claims.identities ?? '');
const provider = identities.includes('SignInWithApple')
? 'apple'
: identities.includes('Google')
? 'google'
: 'email';
return { sub, email, provider };
}
async function currentUsage(user: UserRow) {
let usage = await getUsage(user.id);
if (shouldResetUsage(usage.last_reset, new Date(), config.usageResetDays)) {
await resetUsage(user.id);
usage = await getUsage(user.id);
}
return usage;
}
async function handleMe(user: UserRow): Promise<APIGatewayProxyResultV2> {
const usage = await currentUsage(user);
return json(200, {
subscriptionStatus: user.subscription,
messagesUsed: usage.messages_used,
messageCap: config.messageCap,
});
}
/**
* The paywall is intelligent: no hardcoded message count. In free mode the
* model works in discovery (follow-up questions, patterns, understanding)
* and signals `analysisReady` when a concrete, valuable answer could be
* delivered — that is the paywall moment, returned as `paywall: true`.
* Premium mode delivers the full analysis, including immediately after
* unlock (the transcript then ends with the assistant's transition message).
*/
async function handleChat(
user: UserRow,
body: string | undefined,
): Promise<APIGatewayProxyResultV2> {
const premium = user.subscription === 'active';
const parsed = body ? (JSON.parse(body) as { messages?: ChatMessage[] }) : {};
const messages = parsed.messages ?? [];
const last = messages[messages.length - 1];
const validLast =
last &&
typeof last.content === 'string' &&
last.content.trim() &&
(last.role === 'user' || (premium && last.role === 'assistant'));
if (!validLast) {
return json(400, { error: 'messages must end with a non-empty user message' });
}
const usage = await currentUsage(user);
if (!canSendMessage(usage.messages_used, user.subscription, config.messageCap)) {
return json(402, { error: 'message_cap_reached' });
}
const result = await generateReply(messages, premium ? 'premium' : 'free');
await incrementUsage(user.id);
return json(200, { reply: result.reply, paywall: result.analysisReady });
}
async function handleVerifyPurchase(
user: UserRow,
body: string | undefined,
): Promise<APIGatewayProxyResultV2> {
const parsed = body ? (JSON.parse(body) as Partial<VerifyPurchaseRequest>) : {};
if (
(parsed.platform !== 'ios' && parsed.platform !== 'android') ||
typeof parsed.productId !== 'string' ||
typeof parsed.receipt !== 'string'
) {
return json(400, { error: 'platform, productId and receipt are required' });
}
const status = await verifyAndApplyPurchase(user.id, parsed as VerifyPurchaseRequest);
return json(200, { subscriptionStatus: status });
}
/** Guests chat before signing in, identified by an app-generated device id. */
async function handleGuestChat(event: APIGatewayProxyEventV2): Promise<APIGatewayProxyResultV2> {
const deviceId = event.headers?.['x-device-id'] ?? '';
if (!/^[0-9a-fA-F-]{8,64}$/.test(deviceId)) {
return json(400, { error: 'a valid X-Device-Id header is required' });
}
const user = await getOrCreateUser(`guest:${deviceId}`, '', 'guest');
return handleChat(user, event.body);
}
export async function handler(event: APIGatewayProxyEventV2): Promise<APIGatewayProxyResultV2> {
try {
const route = `${event.requestContext.http.method} ${event.rawPath}`;
// Public routes (no sign-in before the first question).
if (route === 'GET /suggestions') {
return json(200, { suggestions: suggestionsFile.suggestions });
}
if (route === 'POST /guest/chat') {
return await handleGuestChat(event);
}
// Authorized routes (Cognito JWT, enforced by API Gateway).
const identity = identityFromEvent(event);
if (!identity) return json(401, { error: 'unauthorized' });
const user = await getOrCreateUser(identity.sub, identity.email, identity.provider);
switch (route) {
case 'GET /me':
return await handleMe(user);
case 'DELETE /me':
// In-app account deletion (App Store guideline 5.1.1). Removes the
// user and usage rows; store subscriptions are cancelled by the user
// in App Store / Play settings.
await deleteUser(user.id);
return json(200, { deleted: true });
case 'POST /chat':
return await handleChat(user, event.body);
case 'POST /subscription/verify':
return await handleVerifyPurchase(user, event.body);
default:
return json(404, { error: 'not_found' });
}
} catch (error) {
// Log without request payloads: no conversation content ends up in logs.
console.error('request_failed', error instanceof Error ? error.message : 'unknown');
return json(500, { error: 'internal_error' });
}
}
+19
View File
@@ -0,0 +1,19 @@
import { readdirSync, readFileSync } from 'node:fs';
import { join } from 'node:path';
let cached: string | undefined;
/**
* The knowledge base is a set of Markdown files bundled with the Lambda.
* They are concatenated and injected as system instructions. RAG (selective
* retrieval) can replace this later without changing the API surface.
*/
export function loadKnowledgeBase(): string {
if (cached !== undefined) return cached;
const dir = process.env.KNOWLEDGE_DIR ?? join(import.meta.dirname, '..', 'knowledge');
const files = readdirSync(dir)
.filter((f) => f.endsWith('.md'))
.sort();
cached = files.map((f) => readFileSync(join(dir, f), 'utf-8')).join('\n\n---\n\n');
return cached;
}
+20
View File
@@ -0,0 +1,20 @@
import { GetSecretValueCommand, SecretsManagerClient } from '@aws-sdk/client-secrets-manager';
const client = new SecretsManagerClient({});
const cache = new Map<string, Record<string, string>>();
/**
* Fetches a JSON secret from AWS Secrets Manager and caches it for the
* lifetime of the Lambda container.
*/
export async function getSecret(arn: string): Promise<Record<string, string>> {
const cached = cache.get(arn);
if (cached) return cached;
const result = await client.send(new GetSecretValueCommand({ SecretId: arn }));
if (!result.SecretString) {
throw new Error(`Secret ${arn} has no string value`);
}
const parsed = JSON.parse(result.SecretString) as Record<string, string>;
cache.set(arn, parsed);
return parsed;
}
+49
View File
@@ -0,0 +1,49 @@
import { config } from '../config.js';
import { getSecret } from '../secrets.js';
import type { PaymentProvider, VerificationResult, VerifyPurchaseRequest } from './types.js';
const PRODUCTION_URL = 'https://buy.itunes.apple.com/verifyReceipt';
const SANDBOX_URL = 'https://sandbox.itunes.apple.com/verifyReceipt';
/** Apple status code meaning "this is a sandbox receipt, retry against sandbox". */
const STATUS_SANDBOX_RECEIPT = 21007;
interface AppleResponse {
status: number;
latest_receipt_info?: { product_id: string; expires_date_ms: string }[];
}
async function verifyReceipt(url: string, receipt: string, secret: string): Promise<AppleResponse> {
const response = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
'receipt-data': receipt,
password: secret,
'exclude-old-transactions': true,
}),
});
if (!response.ok) throw new Error(`Apple receipt verification failed (${response.status})`);
return (await response.json()) as AppleResponse;
}
export const appleProvider: PaymentProvider = {
async verify(request: VerifyPurchaseRequest): Promise<VerificationResult> {
const appSecret = await getSecret(config.appSecretArn);
const sharedSecret = appSecret.APPLE_SHARED_SECRET;
if (!sharedSecret) throw new Error('APPLE_SHARED_SECRET missing from application secret');
let data = await verifyReceipt(PRODUCTION_URL, request.receipt, sharedSecret);
if (data.status === STATUS_SANDBOX_RECEIPT) {
data = await verifyReceipt(SANDBOX_URL, request.receipt, sharedSecret);
}
if (data.status !== 0) return { active: false };
const latest = (data.latest_receipt_info ?? [])
.filter((info) => info.product_id === request.productId)
.map((info) => Number(info.expires_date_ms))
.sort((a, b) => b - a)[0];
if (!latest) return { active: false };
return { active: latest > Date.now(), expiresAt: new Date(latest) };
},
};
+66
View File
@@ -0,0 +1,66 @@
import { createSign } from 'node:crypto';
import { config } from '../config.js';
import { getSecret } from '../secrets.js';
import type { PaymentProvider, VerificationResult, VerifyPurchaseRequest } from './types.js';
interface ServiceAccount {
client_email: string;
private_key: string;
}
function base64url(input: string | Buffer): string {
return Buffer.from(input).toString('base64url');
}
/** Mints a Google OAuth access token from a service account key (RS256 JWT). */
async function getAccessToken(account: ServiceAccount): Promise<string> {
const now = Math.floor(Date.now() / 1000);
const header = base64url(JSON.stringify({ alg: 'RS256', typ: 'JWT' }));
const claims = base64url(
JSON.stringify({
iss: account.client_email,
scope: 'https://www.googleapis.com/auth/androidpublisher',
aud: 'https://oauth2.googleapis.com/token',
iat: now,
exp: now + 3600,
}),
);
const signer = createSign('RSA-SHA256');
signer.update(`${header}.${claims}`);
const signature = signer.sign(account.private_key).toString('base64url');
const assertion = `${header}.${claims}.${signature}`;
const response = await fetch('https://oauth2.googleapis.com/token', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer',
assertion,
}),
});
if (!response.ok) throw new Error(`Google token exchange failed (${response.status})`);
const data = (await response.json()) as { access_token: string };
return data.access_token;
}
export const googleProvider: PaymentProvider = {
async verify(request: VerifyPurchaseRequest): Promise<VerificationResult> {
const appSecret = await getSecret(config.appSecretArn);
const raw = appSecret.GOOGLE_SERVICE_ACCOUNT_JSON;
if (!raw) throw new Error('GOOGLE_SERVICE_ACCOUNT_JSON missing from application secret');
const account = JSON.parse(raw) as ServiceAccount;
const token = await getAccessToken(account);
const url =
`https://androidpublisher.googleapis.com/androidpublisher/v3/applications/` +
`${config.androidPackageName}/purchases/subscriptions/` +
`${encodeURIComponent(request.productId)}/tokens/${encodeURIComponent(request.receipt)}`;
const response = await fetch(url, { headers: { Authorization: `Bearer ${token}` } });
if (response.status === 404 || response.status === 410) return { active: false };
if (!response.ok) throw new Error(`Google purchase lookup failed (${response.status})`);
const data = (await response.json()) as { expiryTimeMillis?: string };
const expiry = Number(data.expiryTimeMillis ?? 0);
return { active: expiry > Date.now(), expiresAt: expiry ? new Date(expiry) : undefined };
},
};
+24
View File
@@ -0,0 +1,24 @@
import { setSubscriptionStatus } from '../db.js';
import { appleProvider } from './apple.js';
import { googleProvider } from './google.js';
import type { PaymentProvider, VerifyPurchaseRequest } from './types.js';
const providers: Record<VerifyPurchaseRequest['platform'], PaymentProvider> = {
ios: appleProvider,
android: googleProvider,
};
/**
* Verifies a purchase with the platform's store and updates the user's
* subscription status. Returns the resulting status.
*/
export async function verifyAndApplyPurchase(
userId: string,
request: VerifyPurchaseRequest,
): Promise<'free' | 'active'> {
const provider = providers[request.platform];
const result = await provider.verify(request);
const status = result.active ? 'active' : 'free';
await setSubscriptionStatus(userId, status);
return status;
}
+22
View File
@@ -0,0 +1,22 @@
export type Platform = 'ios' | 'android';
export interface VerifyPurchaseRequest {
platform: Platform;
productId: string;
/** iOS: base64 app receipt. Android: the purchase token from Play Billing. */
receipt: string;
}
export interface VerificationResult {
active: boolean;
expiresAt?: Date;
}
/**
* Shared subscription logic with per-platform payment providers.
* A Stripe adapter can be added here for a future web version without
* touching the rest of the backend.
*/
export interface PaymentProvider {
verify(request: VerifyPurchaseRequest): Promise<VerificationResult>;
}
+17
View File
@@ -0,0 +1,17 @@
/**
* Pure free-tier gating logic, separated from I/O so it can be unit tested.
*/
export function shouldResetUsage(lastReset: Date, now: Date, resetDays: number): boolean {
const ms = resetDays * 24 * 60 * 60 * 1000;
return now.getTime() - lastReset.getTime() >= ms;
}
export function canSendMessage(
messagesUsed: number,
subscriptionStatus: 'free' | 'active',
freeLimit: number,
): boolean {
if (subscriptionStatus === 'active') return true;
return messagesUsed < freeLimit;
}
+12
View File
@@ -0,0 +1,12 @@
{
"suggestions": [
"I need support before a difficult conversation.",
"I keep getting stuck in the same conflicts.",
"Help me make a difficult decision.",
"How do I become more secure in relationships?",
"Help me prepare for a salary negotiation.",
"I want to understand why I react so strongly.",
"How do I build better self-confidence?",
"I want to communicate more clearly."
]
}
+73
View File
@@ -0,0 +1,73 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
vi.mock('../src/secrets.js', () => ({
getSecret: vi.fn(async () => ({ OPENAI_API_KEY: 'test-key' })),
}));
import { generateReply } from '../src/chat.js';
function openAiResponse(reply: string, analysisReady: boolean) {
return {
ok: true,
json: async () => ({
output: [
{
type: 'message',
content: [
{
type: 'output_text',
text: JSON.stringify({ reply, analysis_ready: analysisReady }),
},
],
},
],
}),
text: async () => '',
};
}
const fetchMock = vi.fn();
beforeEach(() => {
process.env.KNOWLEDGE_DIR = 'knowledge';
vi.stubGlobal('fetch', fetchMock);
fetchMock.mockReset();
});
describe('generateReply', () => {
it('parses structured output and surfaces analysis_ready in free mode', async () => {
fetchMock.mockResolvedValue(openAiResponse('A transition.', true));
const result = await generateReply([{ role: 'user', content: 'Hi' }], 'free');
expect(result).toEqual({ reply: 'A transition.', analysisReady: true });
});
it('never reports analysis_ready in premium mode', async () => {
fetchMock.mockResolvedValue(openAiResponse('Full analysis.', true));
const result = await generateReply([{ role: 'user', content: 'Hi' }], 'premium');
expect(result).toEqual({ reply: 'Full analysis.', analysisReady: false });
});
it('sends the knowledge base and a strict JSON schema to the Responses API', async () => {
fetchMock.mockResolvedValue(openAiResponse('Ok.', false));
await generateReply([{ role: 'user', content: 'Hi' }], 'free');
const [url, init] = fetchMock.mock.calls[0] as [string, { body: string }];
expect(url).toBe('https://api.openai.com/v1/responses');
const payload = JSON.parse(init.body) as {
instructions: string;
text: { format: { type: string; strict: boolean } };
};
expect(payload.instructions).toContain('Semantika');
expect(payload.instructions).toContain('Never manufacture suspense');
expect(payload.instructions).toContain('# Neurosemantics');
expect(payload.text.format.type).toBe('json_schema');
expect(payload.text.format.strict).toBe(true);
});
it('throws on a non-ok response', async () => {
fetchMock.mockResolvedValue({ ok: false, status: 500, text: async () => 'boom' });
await expect(generateReply([{ role: 'user', content: 'Hi' }], 'free')).rejects.toThrow(
'OpenAI request failed',
);
});
});
+186
View File
@@ -0,0 +1,186 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
vi.mock('../src/db.js', () => ({
deleteUser: vi.fn(),
getOrCreateUser: vi.fn(),
getUsage: vi.fn(),
incrementUsage: vi.fn(),
resetUsage: vi.fn(),
}));
vi.mock('../src/chat.js', () => ({
generateReply: vi.fn(),
}));
vi.mock('../src/subscription/index.js', () => ({
verifyAndApplyPurchase: vi.fn(),
}));
import { generateReply } from '../src/chat.js';
import { deleteUser, getOrCreateUser, getUsage, incrementUsage } from '../src/db.js';
import { handler } from '../src/handler.js';
import { verifyAndApplyPurchase } from '../src/subscription/index.js';
interface EventOptions {
body?: unknown;
claims?: Record<string, unknown>;
headers?: Record<string, string>;
}
function makeEvent(method: string, path: string, opts: EventOptions = {}) {
return {
rawPath: path,
headers: opts.headers ?? {},
body: opts.body === undefined ? undefined : JSON.stringify(opts.body),
requestContext: {
http: { method },
...(opts.claims ? { authorizer: { jwt: { claims: opts.claims } } } : {}),
},
// The handler only reads the fields above.
} as never;
}
async function call(method: string, path: string, opts: EventOptions = {}) {
const result = (await handler(makeEvent(method, path, opts))) as {
statusCode: number;
body: string;
};
return { status: result.statusCode, body: JSON.parse(result.body) as Record<string, unknown> };
}
const freeUser = {
id: 'user-1',
email: 'a@b.se',
auth_provider: 'email',
subscription: 'free' as const,
created_at: new Date(),
};
const premiumUser = { ...freeUser, subscription: 'active' as const };
const freshUsage = { user_id: 'user-1', messages_used: 0, last_reset: new Date() };
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(getOrCreateUser).mockResolvedValue(freeUser);
vi.mocked(getUsage).mockResolvedValue(freshUsage);
vi.mocked(generateReply).mockResolvedValue({ reply: 'A reply.', analysisReady: false });
});
describe('public routes', () => {
it('serves suggestions without authentication', async () => {
const { status, body } = await call('GET', '/suggestions');
expect(status).toBe(200);
expect(Array.isArray(body.suggestions)).toBe(true);
expect((body.suggestions as string[]).length).toBeGreaterThan(0);
});
it('rejects guest chat without a device id', async () => {
const { status } = await call('POST', '/guest/chat', {
body: { messages: [{ role: 'user', content: 'Hi' }] },
});
expect(status).toBe(400);
});
it('creates a guest user from the device id and chats', async () => {
const { status, body } = await call('POST', '/guest/chat', {
headers: { 'x-device-id': 'aaaa-bbbb-cccc' },
body: { messages: [{ role: 'user', content: 'Hi' }] },
});
expect(status).toBe(200);
expect(body.reply).toBe('A reply.');
expect(getOrCreateUser).toHaveBeenCalledWith('guest:aaaa-bbbb-cccc', '', 'guest');
expect(incrementUsage).toHaveBeenCalledWith('user-1');
});
});
describe('authorization', () => {
it('returns 401 on account routes without JWT claims', async () => {
const { status } = await call('GET', '/me');
expect(status).toBe(401);
});
it('derives the auth provider from the identities claim', async () => {
await call('GET', '/me', {
claims: { sub: 'sub-1', email: 'a@b.se', identities: '[{"providerName":"SignInWithApple"}]' },
});
expect(getOrCreateUser).toHaveBeenCalledWith('sub-1', 'a@b.se', 'apple');
});
it('deletes the account on DELETE /me', async () => {
const { status, body } = await call('DELETE', '/me', {
claims: { sub: 'sub-1', email: 'a@b.se' },
});
expect(status).toBe(200);
expect(body.deleted).toBe(true);
expect(deleteUser).toHaveBeenCalledWith('user-1');
});
});
describe('chat and the intelligent paywall', () => {
const claims = { sub: 'sub-1', email: 'a@b.se' };
const userMessages = { messages: [{ role: 'user', content: 'Hi' }] };
it('passes the paywall flag through when the analysis is ready', async () => {
vi.mocked(generateReply).mockResolvedValue({ reply: 'Transition.', analysisReady: true });
const { status, body } = await call('POST', '/chat', { claims, body: userMessages });
expect(status).toBe(200);
expect(body).toEqual({ reply: 'Transition.', paywall: true });
expect(generateReply).toHaveBeenCalledWith(userMessages.messages, 'free');
});
it('runs premium chats in premium mode', async () => {
vi.mocked(getOrCreateUser).mockResolvedValue(premiumUser);
await call('POST', '/chat', { claims, body: userMessages });
expect(generateReply).toHaveBeenCalledWith(userMessages.messages, 'premium');
});
it('rejects a transcript ending with an assistant message for free users', async () => {
const { status } = await call('POST', '/chat', {
claims,
body: { messages: [{ role: 'assistant', content: 'Transition.' }] },
});
expect(status).toBe(400);
});
it('accepts an assistant-final transcript for premium users (post-unlock delivery)', async () => {
vi.mocked(getOrCreateUser).mockResolvedValue(premiumUser);
const { status } = await call('POST', '/chat', {
claims,
body: { messages: [{ role: 'assistant', content: 'Transition.' }] },
});
expect(status).toBe(200);
});
it('enforces the abuse cap for free users with 402', async () => {
vi.mocked(getUsage).mockResolvedValue({ ...freshUsage, messages_used: 100000 });
const { status, body } = await call('POST', '/chat', { claims, body: userMessages });
expect(status).toBe(402);
expect(body.error).toBe('message_cap_reached');
});
it('never blocks premium users on the cap', async () => {
vi.mocked(getOrCreateUser).mockResolvedValue(premiumUser);
vi.mocked(getUsage).mockResolvedValue({ ...freshUsage, messages_used: 100000 });
const { status } = await call('POST', '/chat', { claims, body: userMessages });
expect(status).toBe(200);
});
});
describe('purchase verification', () => {
const claims = { sub: 'sub-1', email: 'a@b.se' };
it('validates the request body', async () => {
const { status } = await call('POST', '/subscription/verify', {
claims,
body: { platform: 'windows', productId: 'x' },
});
expect(status).toBe(400);
});
it('returns the resulting subscription status', async () => {
vi.mocked(verifyAndApplyPurchase).mockResolvedValue('active');
const { status, body } = await call('POST', '/subscription/verify', {
claims,
body: { platform: 'ios', productId: 'semantika_monthly', receipt: 'abc' },
});
expect(status).toBe(200);
expect(body.subscriptionStatus).toBe('active');
});
});
+124
View File
@@ -0,0 +1,124 @@
import { generateKeyPairSync } from 'node:crypto';
import { beforeEach, describe, expect, it, vi } from 'vitest';
const { privateKey } = generateKeyPairSync('rsa', {
modulusLength: 2048,
privateKeyEncoding: { type: 'pkcs8', format: 'pem' },
publicKeyEncoding: { type: 'spki', format: 'pem' },
});
vi.mock('../src/secrets.js', () => ({
getSecret: vi.fn(async () => ({
APPLE_SHARED_SECRET: 'apple-secret',
GOOGLE_SERVICE_ACCOUNT_JSON: JSON.stringify({
client_email: 'svc@project.iam.gserviceaccount.com',
private_key: privateKey,
}),
})),
}));
import { appleProvider } from '../src/subscription/apple.js';
import { googleProvider } from '../src/subscription/google.js';
const fetchMock = vi.fn();
beforeEach(() => {
vi.stubGlobal('fetch', fetchMock);
fetchMock.mockReset();
});
function jsonResponse(payload: unknown, status = 200) {
return { ok: status >= 200 && status < 300, status, json: async () => payload };
}
describe('appleProvider', () => {
const request = { platform: 'ios' as const, productId: 'semantika_monthly', receipt: 'r' };
it('marks an unexpired subscription active', async () => {
fetchMock.mockResolvedValue(
jsonResponse({
status: 0,
latest_receipt_info: [
{ product_id: 'semantika_monthly', expires_date_ms: String(Date.now() + 60_000) },
],
}),
);
const result = await appleProvider.verify(request);
expect(result.active).toBe(true);
});
it('retries against the sandbox on status 21007', async () => {
fetchMock.mockResolvedValueOnce(jsonResponse({ status: 21007 })).mockResolvedValueOnce(
jsonResponse({
status: 0,
latest_receipt_info: [
{ product_id: 'semantika_monthly', expires_date_ms: String(Date.now() + 60_000) },
],
}),
);
const result = await appleProvider.verify(request);
expect(result.active).toBe(true);
expect(fetchMock.mock.calls[0]?.[0]).toContain('buy.itunes.apple.com');
expect(fetchMock.mock.calls[1]?.[0]).toContain('sandbox.itunes.apple.com');
});
it('marks an expired subscription inactive', async () => {
fetchMock.mockResolvedValue(
jsonResponse({
status: 0,
latest_receipt_info: [
{ product_id: 'semantika_monthly', expires_date_ms: String(Date.now() - 60_000) },
],
}),
);
const result = await appleProvider.verify(request);
expect(result.active).toBe(false);
});
it('ignores receipts for other products', async () => {
fetchMock.mockResolvedValue(
jsonResponse({
status: 0,
latest_receipt_info: [
{ product_id: 'something_else', expires_date_ms: String(Date.now() + 60_000) },
],
}),
);
const result = await appleProvider.verify(request);
expect(result.active).toBe(false);
});
});
describe('googleProvider', () => {
const request = { platform: 'android' as const, productId: 'semantika_yearly', receipt: 'token' };
it('exchanges a signed JWT for a token and checks expiry', async () => {
fetchMock
.mockResolvedValueOnce(jsonResponse({ access_token: 'oauth-token' }))
.mockResolvedValueOnce(jsonResponse({ expiryTimeMillis: String(Date.now() + 60_000) }));
const result = await googleProvider.verify(request);
expect(result.active).toBe(true);
expect(fetchMock.mock.calls[0]?.[0]).toBe('https://oauth2.googleapis.com/token');
expect(fetchMock.mock.calls[1]?.[0]).toContain(
'/purchases/subscriptions/semantika_yearly/tokens/token',
);
expect(fetchMock.mock.calls[1]?.[1]?.headers?.Authorization).toBe('Bearer oauth-token');
});
it('treats a gone purchase (410) as inactive', async () => {
fetchMock
.mockResolvedValueOnce(jsonResponse({ access_token: 'oauth-token' }))
.mockResolvedValueOnce(jsonResponse({}, 410));
const result = await googleProvider.verify(request);
expect(result.active).toBe(false);
});
it('marks an expired purchase inactive', async () => {
fetchMock
.mockResolvedValueOnce(jsonResponse({ access_token: 'oauth-token' }))
.mockResolvedValueOnce(jsonResponse({ expiryTimeMillis: String(Date.now() - 60_000) }));
const result = await googleProvider.verify(request);
expect(result.active).toBe(false);
});
});
+32
View File
@@ -0,0 +1,32 @@
import { describe, expect, it } from 'vitest';
import { canSendMessage, shouldResetUsage } from '../src/usage.js';
describe('canSendMessage', () => {
it('allows messages under the free limit', () => {
expect(canSendMessage(0, 'free', 50)).toBe(true);
expect(canSendMessage(49, 'free', 50)).toBe(true);
});
it('blocks free users at the limit', () => {
expect(canSendMessage(50, 'free', 50)).toBe(false);
expect(canSendMessage(51, 'free', 50)).toBe(false);
});
it('never blocks active subscribers', () => {
expect(canSendMessage(10_000, 'active', 50)).toBe(true);
});
});
describe('shouldResetUsage', () => {
const day = 24 * 60 * 60 * 1000;
it('does not reset within the window', () => {
const now = new Date();
expect(shouldResetUsage(new Date(now.getTime() - 29 * day), now, 30)).toBe(false);
});
it('resets once the window has passed', () => {
const now = new Date();
expect(shouldResetUsage(new Date(now.getTime() - 30 * day), now, 30)).toBe(true);
});
});
+12
View File
@@ -0,0 +1,12 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler",
"lib": ["ES2022"],
"types": ["node"],
"noEmit": true
},
"include": ["src", "test"]
}
-42
View File
@@ -1,42 +0,0 @@
#root {
max-width: none;
margin: 0;
padding: 0;
text-align: inherit;
}
.logo {
height: 6em;
padding: 1.5em;
will-change: filter;
transition: filter 300ms;
}
.logo:hover {
filter: drop-shadow(0 0 2em #646cffaa);
}
.logo.react:hover {
filter: drop-shadow(0 0 2em #61dafbaa);
}
@keyframes logo-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
@media (prefers-reduced-motion: no-preference) {
a:nth-of-type(2) .logo {
animation: logo-spin infinite 20s linear;
}
}
.card {
padding: 2em;
}
.read-the-docs {
color: #888;
}
-53
View File
@@ -1,53 +0,0 @@
import { Toaster } from "@/components/ui/toaster";
import { Toaster as Sonner } from "@/components/ui/sonner";
import { TooltipProvider } from "@/components/ui/tooltip";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { BrowserRouter, Routes, Route } from "react-router-dom";
import Index from "./pages/Index";
import NotFound from "./pages/NotFound";
import ResetPassword from "./pages/ResetPassword";
import Profile from "./pages/Profile";
import Account from "./pages/Account";
import AccountConfirmation from "./pages/AccountConfirmation";
import Contact from "./pages/Contact";
import Admin from "./pages/Admin";
import FAQ from "./pages/FAQ";
import OrderConfirmation from "./pages/OrderConfirmation";
import ChangeSubscription from "./pages/ChangeSubscription";
import { PaymentTestModeBanner } from "@/components/PaymentTestModeBanner";
const queryClient = new QueryClient();
function AppContent() {
return (
<BrowserRouter>
<PaymentTestModeBanner />
<Routes>
<Route path="/" element={<Index />} />
<Route path="/contact" element={<Contact />} />
<Route path="/faq" element={<FAQ />} />
<Route path="/reset-password" element={<ResetPassword />} />
<Route path="/profile" element={<Profile />} />
<Route path="/account" element={<Account />} />
<Route path="/account/confirmation" element={<AccountConfirmation />} />
<Route path="/admin" element={<Admin />} />
<Route path="/order-confirmation" element={<OrderConfirmation />} />
<Route path="/account/change-subscription" element={<ChangeSubscription />} />
{/* ADD ALL CUSTOM ROUTES ABOVE THE CATCH-ALL "*" ROUTE */}
<Route path="*" element={<NotFound />} />
</Routes>
</BrowserRouter>
);
}
const App = () => (
<QueryClientProvider client={queryClient}>
<TooltipProvider>
<Toaster />
<Sonner />
<AppContent />
</TooltipProvider>
</QueryClientProvider>
);
export default App;
Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 391 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 MiB

-84
View File
@@ -1,84 +0,0 @@
import { useState } from "react";
import { useNavigate } from "react-router-dom";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Button } from "@/components/ui/button";
import { useAuth } from "@/hooks/useAuth";
import AuthDialog from "./AuthDialog";
const AccountMenu = () => {
const { user, loading } = useAuth();
const [authDialogOpen, setAuthDialogOpen] = useState(false);
const [authMode, setAuthMode] = useState<"login" | "signup">("login");
const navigate = useNavigate();
const handleOpenAuth = (mode: "login" | "signup") => {
setAuthMode(mode);
setAuthDialogOpen(true);
};
if (loading) {
return null;
}
// If user is logged in, navigate directly to account page
if (user) {
return (
<Button
variant="ghost"
onClick={() => navigate("/account")}
className="text-primary hover:text-primary/80 font-serif text-base font-bold border-2 hover:bg-[rgba(220,38,38,0.3)]"
style={{ borderColor: '#dc2626' }}
>
Mitt konto
</Button>
);
}
// If not logged in, show dropdown with login/signup options
return (
<>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
className="text-primary hover:text-primary/80 font-serif text-base font-bold border-2 hover:bg-[rgba(220,38,38,0.3)]"
style={{ borderColor: '#dc2626' }}
>
Mitt konto
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
className="bg-background border border-border shadow-lg z-50 p-0 w-[var(--radix-dropdown-menu-trigger-width)]"
>
<button
onClick={() => handleOpenAuth("signup")}
className="w-full px-4 py-2 text-left text-sm hover:bg-[rgba(220,38,38,0.3)] focus:bg-[rgba(220,38,38,0.3)] transition-colors"
>
Skapa konto
</button>
<button
onClick={() => handleOpenAuth("login")}
className="w-full px-4 py-2 text-left text-sm hover:bg-[rgba(220,38,38,0.3)] focus:bg-[rgba(220,38,38,0.3)] hover:text-black focus:text-black transition-colors"
>
Logga in
</button>
</DropdownMenuContent>
</DropdownMenu>
<AuthDialog
open={authDialogOpen}
onOpenChange={setAuthDialogOpen}
mode={authMode}
onModeChange={setAuthMode}
/>
</>
);
};
export default AccountMenu;
-262
View File
@@ -1,262 +0,0 @@
import { useState } from "react";
import { z } from "zod";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { useAuth } from "@/hooks/useAuth";
import { useToast } from "@/hooks/use-toast";
const authSchema = z.object({
email: z.string().trim().email({ message: "Ogiltig e-postadress" }).max(255),
password: z.string().min(6, { message: "Lösenordet måste vara minst 6 tecken" }).max(100),
});
const emailSchema = z.object({
email: z.string().trim().email({ message: "Ogiltig e-postadress" }).max(255),
});
type AuthMode = "login" | "signup" | "forgot";
interface AuthDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
mode: "login" | "signup";
onModeChange: (mode: "login" | "signup") => void;
}
const AuthDialog = ({ open, onOpenChange, mode: initialMode, onModeChange }: AuthDialogProps) => {
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [isSubmitting, setIsSubmitting] = useState(false);
const [internalMode, setInternalMode] = useState<AuthMode>(initialMode);
const { signIn, signUp, resetPassword } = useAuth();
const { toast } = useToast();
// Sync internal mode with external mode prop
const mode = internalMode === "forgot" ? "forgot" : initialMode;
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (internalMode === "forgot") {
const validation = emailSchema.safeParse({ email });
if (!validation.success) {
toast({
title: "Fel",
description: validation.error.errors[0].message,
variant: "destructive",
});
return;
}
setIsSubmitting(true);
try {
const { error } = await resetPassword(email);
if (error) {
toast({
title: "Kunde inte skicka återställningslänk",
description: error.message,
variant: "destructive",
});
} else {
toast({
title: "E-post skickad!",
description: "Kolla din inkorg för att återställa ditt lösenord.",
});
onOpenChange(false);
resetForm();
}
} finally {
setIsSubmitting(false);
}
return;
}
const validation = authSchema.safeParse({ email, password });
if (!validation.success) {
toast({
title: "Fel",
description: validation.error.errors[0].message,
variant: "destructive",
});
return;
}
setIsSubmitting(true);
try {
if (initialMode === "signup") {
const { error } = await signUp(email, password);
if (error) {
toast({
title: "Kunde inte skapa konto",
description: error.message,
variant: "destructive",
});
} else {
toast({
title: "Konto skapat!",
description: "Du är nu inloggad.",
});
onOpenChange(false);
resetForm();
}
} else {
const { error } = await signIn(email, password);
if (error) {
toast({
title: "Kunde inte logga in",
description: error.message,
variant: "destructive",
});
} else {
toast({
title: "Inloggad!",
description: "Välkommen tillbaka.",
});
onOpenChange(false);
resetForm();
}
}
} finally {
setIsSubmitting(false);
}
};
const resetForm = () => {
setEmail("");
setPassword("");
setInternalMode(initialMode);
};
const handleOpenChange = (newOpen: boolean) => {
if (!newOpen) {
resetForm();
}
onOpenChange(newOpen);
};
const getTitle = () => {
if (internalMode === "forgot") return "Glömt lösenord";
return initialMode === "signup" ? "Skapa konto" : "Logga in";
};
const getSubmitText = () => {
if (isSubmitting) return "Laddar...";
if (internalMode === "forgot") return "Skicka återställningslänk";
return initialMode === "signup" ? "Skapa konto" : "Logga in";
};
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="bg-background border border-border max-w-md">
<DialogHeader>
<DialogTitle className="font-serif text-xl text-foreground">
{getTitle()}
</DialogTitle>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4 mt-4">
<div className="space-y-2">
<Label htmlFor="email">E-post</Label>
<Input
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="din@email.se"
required
/>
</div>
{internalMode !== "forgot" && (
<div className="space-y-2">
<Label htmlFor="password">Lösenord</Label>
<Input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="••••••••"
required
/>
</div>
)}
<Button
type="submit"
className="w-full bg-primary text-primary-foreground hover:bg-primary/90"
disabled={isSubmitting}
>
{getSubmitText()}
</Button>
</form>
<div className="text-center mt-4 space-y-2">
{internalMode === "forgot" ? (
<p className="text-sm text-muted-foreground">
<button
type="button"
onClick={() => setInternalMode("login")}
className="text-primary hover:underline"
>
Tillbaka till inloggning
</button>
</p>
) : (
<>
{initialMode === "login" && (
<p className="text-sm text-muted-foreground">
<button
type="button"
onClick={() => setInternalMode("forgot")}
className="text-primary hover:underline"
>
Glömt lösenord?
</button>
</p>
)}
{initialMode === "signup" ? (
<p className="text-sm text-muted-foreground">
Har du redan ett konto?{" "}
<button
type="button"
onClick={() => {
setInternalMode("login");
onModeChange("login");
}}
className="text-primary hover:underline"
>
Logga in
</button>
</p>
) : (
<p className="text-sm text-muted-foreground">
Har du inget konto?{" "}
<button
type="button"
onClick={() => {
setInternalMode("signup");
onModeChange("signup");
}}
className="text-primary hover:underline"
>
Skapa konto
</button>
</p>
)}
</>
)}
</div>
</DialogContent>
</Dialog>
);
};
export default AuthDialog;
-53
View File
@@ -1,53 +0,0 @@
import { Truck, Clock, MapPin } from "lucide-react";
const DeliverySection = () => {
return (
<section className="py-8 md:py-12 px-4 md:px-6 bg-card">
<div className="max-w-4xl mx-auto">
{/* Info cards */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 md:gap-6">
{/* Leveransområde */}
<div className="text-center p-4">
<div className="w-10 h-10 mx-auto mb-3 rounded-full bg-primary/10 flex items-center justify-center">
<MapPin className="w-5 h-5 text-primary" />
</div>
<h3 className="font-display text-base md:text-lg font-semibold mb-1">
Storstockholm
</h3>
<p className="text-muted-foreground text-sm">
Vi levererar till alla företag inom Storstockholmsområdet.
</p>
</div>
{/* Leveransdag */}
<div className="text-center p-4">
<div className="w-10 h-10 mx-auto mb-3 rounded-full bg-primary/10 flex items-center justify-center">
<Clock className="w-5 h-5 text-primary" />
</div>
<h3 className="font-display text-base md:text-lg font-semibold mb-1">
Veckoleverans
</h3>
<p className="text-muted-foreground text-sm">
Kakorna levereras samma dag varje vecka.
</p>
</div>
{/* Leveranstid */}
<div className="text-center p-4">
<div className="w-10 h-10 mx-auto mb-3 rounded-full bg-primary/10 flex items-center justify-center">
<Truck className="w-5 h-5 text-primary" />
</div>
<h3 className="font-display text-base md:text-lg font-semibold mb-1">
Dagtid
</h3>
<p className="text-muted-foreground text-sm">
Kakorna levereras alltid under dagtid.
</p>
</div>
</div>
</div>
</section>
);
};
export default DeliverySection;
-66
View File
@@ -1,66 +0,0 @@
import logoImage from "@/assets/logo.png";
import { Instagram } from "lucide-react";
const TikTokIcon = ({ className }: { className?: string }) => (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
className={className}
width="24"
height="24"
>
<path d="M19.59 6.69a4.83 4.83 0 0 1-3.77-4.25V2h-3.45v13.67a2.89 2.89 0 0 1-5.2 1.74 2.89 2.89 0 0 1 2.31-4.64 2.93 2.93 0 0 1 .88.13V9.4a6.84 6.84 0 0 0-1-.05A6.33 6.33 0 0 0 5 20.1a6.34 6.34 0 0 0 10.86-4.43v-7a8.16 8.16 0 0 0 4.77 1.52v-3.4a4.85 4.85 0 0 1-1-.1z"/>
</svg>
);
const Footer = () => {
return (
<footer className="py-8 px-6 bg-card border-t border-border">
<div className="max-w-5xl mx-auto">
{/* Social Media */}
<div className="flex justify-center items-center gap-6 mb-6">
<a
href="https://instagram.com/konditorivaror"
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-2 text-muted-foreground hover:text-primary transition-colors"
>
<Instagram size={18} />
<span className="text-sm">@konditorivaror</span>
</a>
<a
href="https://tiktok.com/@konditorivaror"
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-2 text-muted-foreground hover:text-primary transition-colors"
>
<TikTokIcon className="w-[18px] h-[18px]" />
<span className="text-sm">@konditorivaror</span>
</a>
</div>
{/* Links */}
<div className="flex flex-wrap justify-center gap-6 text-sm text-muted-foreground mb-6">
<a href="#gallery" className="hover:text-primary transition-colors">
Vårt sortiment
</a>
<a href="/contact" className="hover:text-primary transition-colors">
Kontakta oss
</a>
<a href="/faq" className="hover:text-primary transition-colors">
FAQ
</a>
</div>
{/* Copyright */}
<div className="text-center text-muted-foreground text-xs">
<p>© {new Date().getFullYear()} Lennart Svensson Konditorivaror</p>
<p className="mt-0.5 italic">Alla rättigheter förbehållna</p>
</div>
</div>
</footer>
);
};
export default Footer;
-50
View File
@@ -1,50 +0,0 @@
import bakery1 from "@/assets/bakery-1.jpg";
import bakery2 from "@/assets/bakery-2.jpg";
import bakery3 from "@/assets/bakery-3.jpg";
import bakery4 from "@/assets/bakery-4.jpg";
import bakery5 from "@/assets/bakery-5.jpg";
import bakery6 from "@/assets/bakery-6.jpg";
const images = [
{ src: bakery1, alt: "Kanelbullar" },
{ src: bakery2, alt: "Prinsesstårta" },
{ src: bakery3, alt: "Sorterade kakor" },
{ src: bakery4, alt: "Hantverksbageri" },
{ src: bakery5, alt: "Kardemummabullar" },
{ src: bakery6, alt: "Företagsleverans" },
];
const GallerySection = () => {
return (
<section id="gallery" className="py-24 px-6 bg-card">
<div className="max-w-5xl mx-auto">
{/* Image grid with classic frames */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
{images.map((image, index) => (
<div
key={index}
className="group"
>
{/* Classic frame effect */}
<div className="relative p-3 bg-background border border-border card-classic">
<div className="overflow-hidden">
<img
src={image.src}
alt={image.alt}
className="w-full h-56 object-cover transition-transform duration-500 group-hover:scale-105 sepia-[.15]"
/>
</div>
<p className="text-center mt-3 font-display text-lg text-muted-foreground italic">
{image.alt}
</p>
</div>
</div>
))}
</div>
</div>
</section>
);
};
export default GallerySection;
-22
View File
@@ -1,22 +0,0 @@
import AccountMenu from "./AccountMenu";
const Header = () => {
return (
<header className="fixed top-0 left-0 right-0 z-50 bg-background/95 backdrop-blur-sm border-b border-border">
<div className="px-6 py-3 flex items-center justify-between">
<a href="/" className="flex items-center">
<img
alt="Lennart Svensson Konditorivaror"
className="h-16 w-auto"
src="/lovable-uploads/bc36fcd7-0c6f-48d2-9921-4323413739c7.png"
/>
</a>
<div className="flex items-center gap-3">
<AccountMenu />
</div>
</div>
</header>
);
};
export default Header;
-132
View File
@@ -1,132 +0,0 @@
import { useEffect, useRef, useState } from "react";
import mandelkubbImage from "@/assets/mandelkubb-plate.jpg";
import kolasnittImage from "@/assets/kolasnitt-plate.jpg";
import chokladsnittImage from "@/assets/chokladsnitt-plate.jpg";
const cookies = [
{
name: "Mandelkubben",
image: mandelkubbImage,
description: "Den traditionella mandelkubben nötig och klassisk med mild sötma och tydlig mandelsmak. En riktig svensk fikakaka.",
},
{
name: "Kolasnitten",
image: kolasnittImage,
description: "Den klassiska kolakakan med fyllig, smörig kolasmak som smälter i munnen. Spröd på ytan, seg i mitten.",
},
{
name: "Chokladsnitten",
image: chokladsnittImage,
description: "En chokladälskares favorit perfekt balans mellan sötma och rik chokladsmak. Krispig på kanterna, seg i mitten.",
},
];
const HeritageSection = () => {
const [heroVisible, setHeroVisible] = useState(false);
const [cookiesVisible, setCookiesVisible] = useState(false);
const heroRef = useRef<HTMLDivElement>(null);
const cookiesRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const observerOptions = {
threshold: 0.1,
rootMargin: "0px 0px -100px 0px",
};
const heroObserver = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
setHeroVisible(entry.isIntersecting);
});
}, observerOptions);
const cookiesObserver = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
setCookiesVisible(entry.isIntersecting);
});
}, observerOptions);
if (heroRef.current) heroObserver.observe(heroRef.current);
if (cookiesRef.current) cookiesObserver.observe(cookiesRef.current);
return () => {
heroObserver.disconnect();
cookiesObserver.disconnect();
};
}, []);
return (
<section className="bg-background py-24 px-6">
<div className="max-w-6xl mx-auto">
{/* Heritage Hero */}
<div
ref={heroRef}
className={`transition-all duration-[4000ms] ease-out ${
heroVisible
? "opacity-100 translate-y-0 scale-100"
: "opacity-0 translate-y-24 scale-95"
}`}
>
{/* Text Content */}
<div className="text-center max-w-3xl mx-auto">
<div className="w-24 h-px bg-primary/40 mx-auto mb-6"></div>
<h2 className="font-display text-3xl md:text-4xl lg:text-5xl font-semibold mb-6">
Hantverk & Kvalitet
</h2>
<div className="space-y-4 text-foreground/80 text-lg leading-relaxed">
<p>
Våra recept grundar sig i det klassiska <em className="text-primary">Svenska Konditorilexikon från 1954</em> och
har förädlats genom generationer av bagare. Vi använder bara riktigt smör,
färska mandlar och äkta choklad.
</p>
<p>
Varje kaka formas för hand med samma omsorg som traditionella
konditorer alltid har gjort. Det är det har varit i över 70 år, och det alltid kommer att vara.
</p>
</div>
</div>
</div>
{/* Cookies Grid */}
<div
id="gallery"
ref={cookiesRef}
className="mt-24 grid grid-cols-1 md:grid-cols-3 gap-8 scroll-mt-24"
>
{cookies.map((cookie, index) => (
<div
key={cookie.name}
className={`transition-all duration-700 ease-out ${
cookiesVisible
? "opacity-100 translate-y-0"
: "opacity-0 translate-y-12"
}`}
style={{
transitionDelay: cookiesVisible ? `${index * 200}ms` : "0ms",
}}
>
<div className="text-center">
<div className="aspect-square overflow-hidden rounded-sm mb-6">
<img
src={cookie.image}
alt={cookie.name}
className="w-full h-full object-cover hover:scale-105 transition-transform duration-500"
/>
</div>
<h3 className="font-display text-2xl font-semibold text-foreground mb-3">
{cookie.name}
</h3>
<p className="text-foreground/70 leading-relaxed">
{cookie.description}
</p>
</div>
</div>
))}
</div>
</div>
</section>
);
};
export default HeritageSection;
-38
View File
@@ -1,38 +0,0 @@
import logoImage from "@/assets/logo.png";
import heroBakeryImage from "@/assets/hero-bakery-wide.jpg";
const HeroSection = () => {
return <section className="relative left-1/2 w-screen -translate-x-1/2 overflow-x-clip flex flex-col justify-center items-center text-center min-h-[85vh] pb-0 mb-0">
{/* Background image */}
<div className="pointer-events-none absolute inset-0 bg-cover bg-center" style={{
backgroundImage: `url(${heroBakeryImage})`,
opacity: 0.4,
maskImage: 'linear-gradient(to bottom, black 70%, transparent 100%)',
WebkitMaskImage: 'linear-gradient(to bottom, black 70%, transparent 100%)'
}} />
<div className="animate-fade-up max-w-4xl relative z-10 px-6 py-16">
<img alt="Lennart Svensson Konditorivaror" className="h-56 md:h-72 lg:h-80 w-auto mx-auto mb-1 mt-8" src="/lovable-uploads/8658a13b-f1d4-45db-b92b-3e22abb6ee1b.png" />
<p className="text-muted-foreground text-lg mb-4">
Svenskt konditorhantverk sedan 1953
</p>
<div className="w-40 h-px bg-primary/40 mx-auto my-4"></div>
<p className="max-w-xl mx-auto text-foreground text-xl mb-8 leading-relaxed">
Äkta fika levererat till ditt företag varje vecka
</p>
<div className="flex flex-col sm:flex-row gap-4 justify-center">
<a href="#abonnemang" className="btn-classic inline-block px-10 py-4 text-lg rounded-sm">
Starta Prenumeration
</a>
<a href="#gallery" className="inline-block px-10 py-4 text-lg rounded-sm border-2 border-primary text-primary hover:bg-primary hover:text-primary-foreground transition-colors">
Sortiment
</a>
</div>
</div>
</section>;
};
export default HeroSection;
-19
View File
@@ -1,19 +0,0 @@
import logoImage from "@/assets/logo.png";
const Logo = ({ className = "", size = "default" }: { className?: string; size?: "small" | "default" | "large" }) => {
const sizeClasses = {
small: "h-16",
default: "h-24",
large: "h-40"
};
return (
<img
src={logoImage}
alt="Lennart Svensson Konditorivaror"
className={`${sizeClasses[size]} w-auto ${className}`}
/>
);
};
export default Logo;
-28
View File
@@ -1,28 +0,0 @@
import { NavLink as RouterNavLink, NavLinkProps } from "react-router-dom";
import { forwardRef } from "react";
import { cn } from "@/lib/utils";
interface NavLinkCompatProps extends Omit<NavLinkProps, "className"> {
className?: string;
activeClassName?: string;
pendingClassName?: string;
}
const NavLink = forwardRef<HTMLAnchorElement, NavLinkCompatProps>(
({ className, activeClassName, pendingClassName, to, ...props }, ref) => {
return (
<RouterNavLink
ref={ref}
to={to}
className={({ isActive, isPending }) =>
cn(className, isActive && activeClassName, isPending && pendingClassName)
}
{...props}
/>
);
},
);
NavLink.displayName = "NavLink";
export { NavLink };
-19
View File
@@ -1,19 +0,0 @@
const clientToken = import.meta.env.VITE_PAYMENTS_CLIENT_TOKEN as string | undefined;
export function PaymentTestModeBanner() {
if (!clientToken) {
return (
<div className="w-full bg-red-100 border-b border-red-300 px-4 py-2 text-center text-sm text-red-800">
Skarpa betalningar är inte konfigurerade. Slutför go-live i Payments-fliken.
</div>
);
}
if (clientToken.startsWith("pk_test_")) {
return (
<div className="w-full bg-orange-100 border-b border-orange-300 px-4 py-2 text-center text-sm text-orange-800">
Alla betalningar i förhandsvisningen är i testläge. Använd kort <strong>4242 4242 4242 4242</strong>, valfritt framtida datum och CVC.
</div>
);
}
return null;
}
-707
View File
@@ -1,707 +0,0 @@
import { useState, useMemo, useEffect } from "react";
import { Loader2, CheckCircle2, CreditCard, FileText, ChevronsUpDown, Check, Lock, Eye, EyeOff, Sparkles } from "lucide-react";
import { supabase } from "@/integrations/supabase/client";
import { useAuth } from "@/hooks/useAuth";
import { toast } from "sonner";
import { z } from "zod";
import { StripeEmbeddedCheckout } from "@/components/StripeEmbeddedCheckout";
import { getStripeEnvironment, hasPaymentsConfigured } from "@/lib/stripe";
import { cn } from "@/lib/utils";
const STORSTOCKHOLM_CITIES = [
"Stockholm", "Solna", "Sundbyberg", "Nacka", "Lidingö", "Danderyd",
"Täby", "Sollentuna", "Järfälla", "Upplands Väsby", "Sigtuna",
"Vallentuna", "Österåker", "Vaxholm", "Värmdö", "Tyresö", "Haninge",
"Nynäshamn", "Botkyrka", "Salem", "Södertälje", "Nykvarn", "Huddinge",
"Bromma", "Ekerö", "Upplands-Bro",
];
// Map första 3 siffrorna i postnummer → kommun/ort inom Storstockholm.
const POSTAL_PREFIX_TO_CITY: Record<string, string> = {
"100": "Stockholm", "101": "Stockholm", "102": "Stockholm", "103": "Stockholm",
"104": "Stockholm", "105": "Stockholm", "106": "Stockholm", "107": "Stockholm",
"108": "Stockholm", "110": "Stockholm", "111": "Stockholm", "112": "Stockholm",
"113": "Stockholm", "114": "Stockholm", "115": "Stockholm", "116": "Stockholm",
"117": "Stockholm", "118": "Stockholm", "119": "Stockholm", "120": "Stockholm",
"121": "Stockholm", "122": "Stockholm", "123": "Stockholm", "124": "Stockholm",
"125": "Stockholm", "126": "Stockholm", "127": "Stockholm", "128": "Stockholm",
"129": "Stockholm",
"131": "Nacka", "132": "Nacka", "133": "Nacka", "138": "Nacka",
"134": "Värmdö", "139": "Värmdö",
"135": "Tyresö",
"136": "Haninge", "137": "Haninge",
"141": "Huddinge", "142": "Huddinge", "143": "Huddinge",
"144": "Salem",
"145": "Botkyrka", "146": "Botkyrka", "147": "Botkyrka",
"148": "Nynäshamn", "149": "Nynäshamn",
"150": "Södertälje", "151": "Södertälje", "152": "Södertälje", "153": "Södertälje",
"155": "Nykvarn",
"168": "Bromma", "169": "Bromma",
"170": "Solna", "171": "Solna", "173": "Solna",
"172": "Sundbyberg", "174": "Sundbyberg",
"175": "Järfälla", "176": "Järfälla", "177": "Järfälla",
"178": "Ekerö", "179": "Ekerö",
"181": "Lidingö",
"182": "Danderyd",
"183": "Täby", "187": "Täby",
"184": "Österåker",
"185": "Vaxholm",
"186": "Vallentuna",
"191": "Sollentuna", "192": "Sollentuna",
"193": "Sigtuna", "195": "Sigtuna",
"194": "Upplands Väsby",
"196": "Upplands-Bro", "197": "Upplands-Bro",
};
const cityFromPostalCode = (postalCode: string): string | null => {
const cleaned = postalCode.replace(/\s/g, "");
if (!/^\d{5}$/.test(cleaned)) return null;
return POSTAL_PREFIX_TO_CITY[cleaned.slice(0, 3)] ?? null;
};
const PRICE_PER_KG_EXKL = 325; // exkl. moms, används för Stripe/faktura
const VAT_RATE = 0.25;
const PRICE_PER_KG = PRICE_PER_KG_EXKL * (1 + VAT_RATE); // 406,25 kr ink moms visas för kunden
const KG_PER_EMPLOYEE = 0.3;
const WEEKS_PER_MONTH = 4;
const isStockholmPostalCode = (postalCode: string) => {
const cleaned = postalCode.replace(/\s/g, "");
if (!/^\d{5}$/.test(cleaned)) return false;
const num = parseInt(cleaned, 10);
return num >= 10000 && num <= 19999;
};
const stockholmDeliveryMessage = "Vi levererar endast till företag inom Storstockholm.";
const formSchema = z.object({
företag: z.string().trim().min(1, "Fyll i företagsnamn").max(100),
epost: z.string().trim().email("Ange en giltig e-postadress").max(255),
telefon: z.string().trim().max(30).optional(),
adress: z.string().trim().min(1, "Fyll i leveransadress").max(200),
postnummer: z
.string()
.trim()
.min(1, "Fyll i postnummer")
.refine(isStockholmPostalCode, "Vi levererar endast inom Storstockholm (100 00 199 99)"),
stad: z.string().trim().min(1, "Fyll i stad").max(100),
kommentarer: z.string().trim().max(1000).optional(),
password: z.string().max(100).optional(),
});
type Method = "card" | "invoice";
const PlansSection = () => {
const { user } = useAuth();
const [employees, setEmployees] = useState(10);
const [showForm, setShowForm] = useState(false);
const [method, setMethod] = useState<Method>("card");
const [submitting, setSubmitting] = useState(false);
const [submitted, setSubmitted] = useState<null | "card" | "invoice">(null);
const [errors, setErrors] = useState<Record<string, string>>({});
const [clientSecret, setClientSecret] = useState<string | null>(null);
const [cityOpen, setCityOpen] = useState(false);
const [showPassword, setShowPassword] = useState(false);
const [form, setForm] = useState({
företag: "",
epost: "",
telefon: "",
adress: "",
postnummer: "",
stad: "",
kommentarer: "",
password: "",
});
const filteredCities = useMemo(() => {
const query = form.stad.trim().toLocaleLowerCase("sv-SE");
if (!query) return STORSTOCKHOLM_CITIES;
return STORSTOCKHOLM_CITIES.filter((city) =>
city.toLocaleLowerCase("sv-SE").includes(query)
);
}, [form.stad]);
const recommendedKg = useMemo(
() => Math.max(1, Math.round(employees * KG_PER_EMPLOYEE)),
[employees]
);
const weeklyPrice = recommendedKg * PRICE_PER_KG;
const monthlyPrice = weeklyPrice * WEEKS_PER_MONTH;
useEffect(() => {
if (!user || !showForm) return;
(async () => {
const { data } = await supabase
.from("profiles")
.select("company_name, phone, street_address, postal_code, city")
.eq("user_id", user.id)
.maybeSingle();
setForm((prev) => {
const postalCode = prev.postnummer || data?.postal_code || "";
const suggestedCity = cityFromPostalCode(postalCode);
return {
...prev,
företag: prev.företag || data?.company_name || "",
epost: prev.epost || user.email || "",
telefon: prev.telefon || data?.phone || "",
adress: prev.adress || data?.street_address || "",
postnummer: postalCode,
stad: suggestedCity || prev.stad || data?.city || "",
};
});
})();
}, [user, showForm]);
useEffect(() => {
const suggestedCity = cityFromPostalCode(form.postnummer);
if (!suggestedCity || form.stad === suggestedCity) return;
setForm((prev) => ({ ...prev, stad: suggestedCity }));
setErrors((prev) => ({ ...prev, stad: "", postnummer: "" }));
}, [form.postnummer, form.stad]);
const handleChange = (
e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>
) => {
const { name, value } = e.target;
setForm((p) => {
const next = { ...p, [name]: value };
if (name === "postnummer") {
const suggested = cityFromPostalCode(value);
if (suggested) next.stad = suggested;
}
return next;
});
if (errors[name]) setErrors((p) => ({ ...p, [name]: "" }));
if (name === "postnummer") {
setErrors((p) => ({ ...p, postnummer: "", stad: "" }));
}
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
const result = formSchema.safeParse(form);
if (!result.success) {
const fe: Record<string, string> = {};
result.error.errors.forEach((err) => {
if (err.path[0]) fe[err.path[0] as string] = err.message;
});
setErrors(fe);
return;
}
// Password required for guests (auto-create account)
if (!user) {
const pw = form.password || "";
if (pw.length < 8) {
setErrors((prev) => ({ ...prev, password: "Lösenordet måste vara minst 8 tecken" }));
return;
}
}
if (!hasPaymentsConfigured()) {
toast.error("Betalningar är inte konfigurerade ännu.");
return;
}
setSubmitting(true);
try {
// Auto-create account for guests, then sign in
if (!user && form.password) {
const { data: accData, error: accErr } = await supabase.functions.invoke("create-account", {
body: {
email: result.data.epost,
password: form.password,
company: result.data.företag,
phone: result.data.telefon ?? "",
},
});
if (accErr) throw accErr;
if ((accData as any)?.error) throw new Error((accData as any).error);
const { error: signInErr } = await supabase.auth.signInWithPassword({
email: result.data.epost,
password: form.password,
});
if (signInErr) {
if ((accData as any)?.existed) {
throw new Error(
"Ett konto finns redan för denna e-post. Fel lösenord — logga in eller använd 'Glömt lösenord'.",
);
}
throw signInErr;
}
}
const body = {
method,
employees,
kg_per_week: recommendedKg,
company: result.data.företag,
email: result.data.epost,
phone: result.data.telefon ?? "",
address: result.data.adress,
postal_code: result.data.postnummer,
city: result.data.stad,
comments: result.data.kommentarer ?? "",
return_url: `${window.location.origin}/order-confirmation?session_id={CHECKOUT_SESSION_ID}&method=card&kg=${recommendedKg}&email=${encodeURIComponent(result.data.epost)}`,
environment: getStripeEnvironment(),
};
const { data, error } = await supabase.functions.invoke("create-checkout", { body });
if (error) throw error;
if ((data as any)?.error) throw new Error((data as any).error);
if (method === "card") {
if (!(data as any)?.clientSecret) throw new Error("Kunde inte skapa checkout-session");
setClientSecret((data as any).clientSecret);
} else {
window.location.href = `/order-confirmation?method=invoice&kg=${recommendedKg}&email=${encodeURIComponent(result.data.epost)}`;
return;
}
} catch (err: any) {
console.error(err);
toast.error(err?.message || "Kunde inte starta abonnemang. Försök igen.");
} finally {
setSubmitting(false);
}
};
// Show embedded checkout after we get clientSecret
if (clientSecret) {
return (
<section id="abonnemang" className="py-16 md:py-28 px-6 bg-background">
<div className="max-w-3xl mx-auto">
<div className="text-center mb-8">
<div className="w-24 h-px bg-primary/40 mx-auto mb-6"></div>
<h2 className="font-display text-3xl md:text-5xl font-semibold mb-2">
Slutför betalning
</h2>
<p className="text-muted-foreground text-sm">
{recommendedKg} kg/vecka · {monthlyPrice.toLocaleString("sv-SE")} kr/månad (ink moms)
</p>
</div>
<div className="bg-card border border-border rounded-sm p-4 md:p-6">
<StripeEmbeddedCheckout fetchClientSecret={async () => clientSecret} />
</div>
<div className="text-center mt-4">
<button
onClick={() => setClientSecret(null)}
className="text-sm text-muted-foreground underline hover:text-foreground"
>
Avbryt och tillbaka
</button>
</div>
</div>
</section>
);
}
return (
<section id="abonnemang" className="py-16 md:py-28 px-6 bg-background">
<div className="max-w-3xl mx-auto">
<div className="text-center mb-10 md:mb-14">
<div className="w-24 h-px bg-primary/40 mx-auto mb-6"></div>
<h2 className="font-display text-3xl md:text-5xl font-semibold mb-4">
Hur många anställda har ni?
</h2>
<p className="text-muted-foreground text-base md:text-lg max-w-xl mx-auto">
Vi rekommenderar automatiskt rätt mängd kakor utifrån antalet medarbetare.
</p>
</div>
<div className="bg-card border border-border rounded-sm shadow-[0_10px_40px_-15px_rgba(0,0,0,0.15)] p-6 md:p-12">
{submitted === "invoice" ? (
<div className="text-center py-8 animate-fade-in">
<CheckCircle2 className="w-16 h-16 text-primary mx-auto mb-4" />
<h3 className="font-display text-3xl md:text-4xl font-semibold mb-3">
Abonnemang startat!
</h3>
<p className="text-muted-foreground max-w-md mx-auto">
Vi har skapat ditt abonnemang {" "}
<strong className="text-foreground">
{recommendedKg} kg kakor per vecka
</strong>
. Första fakturan skickas till <strong>{form.epost}</strong> inom kort.
</p>
</div>
) : !showForm ? (
<>
<div className="text-center mb-8">
<div
key={employees}
className="font-display text-6xl md:text-8xl font-semibold text-primary tabular-nums animate-fade-in"
>
{employees}
</div>
<div className="text-muted-foreground text-sm md:text-base mt-2 tracking-wide uppercase">
{employees === 1 ? "anställd" : "anställda"}
</div>
</div>
<div className="mb-10 md:mb-12">
<input
type="range"
min={1}
max={100}
step={1}
value={employees}
onChange={(e) => setEmployees(parseInt(e.target.value, 10))}
className="calculator-slider w-full"
aria-label="Antal anställda"
/>
<div className="flex justify-between text-xs text-muted-foreground mt-3">
<span>1</span>
<span>100</span>
</div>
</div>
<div className="text-center border-t border-border pt-8 md:pt-10 space-y-6">
<div>
<div className="text-muted-foreground text-xs md:text-sm uppercase tracking-widest mb-2">
Rekommenderad mängd
</div>
<div className="font-display text-4xl md:text-5xl font-semibold text-foreground tabular-nums">
{recommendedKg} kg
</div>
<div className="text-muted-foreground text-sm mt-1">per vecka</div>
</div>
<div>
<div className="text-muted-foreground text-xs md:text-sm uppercase tracking-widest mb-2">
Pris per månad
</div>
<div className="font-display text-3xl md:text-4xl font-semibold text-primary tabular-nums">
{monthlyPrice.toLocaleString("sv-SE")} kr
</div>
<div className="text-muted-foreground text-xs mt-1">
ink moms
</div>
</div>
<button
onClick={() => setShowForm(true)}
className="btn-classic w-full py-4 text-base md:text-lg rounded-sm mt-2"
>
Starta abonnemang
</button>
</div>
</>
) : (
<form onSubmit={handleSubmit} className="space-y-5 animate-fade-in">
<div className="text-center mb-2">
<div className="text-muted-foreground text-xs uppercase tracking-widest mb-1">
Ditt abonnemang
</div>
<div className="font-display text-2xl md:text-3xl font-semibold">
{recommendedKg} kg/vecka {" "}
<span className="text-primary">
{monthlyPrice.toLocaleString("sv-SE")} kr/mån
</span>
</div>
<div className="text-muted-foreground text-xs">
ink moms · {employees} {employees === 1 ? "anställd" : "anställda"}
</div>
</div>
{/* Betalningsmetod */}
<div>
<label className="block mb-2 text-sm font-semibold">Betalningsmetod</label>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<button
type="button"
onClick={() => setMethod("card")}
className={`flex items-center gap-3 p-4 rounded-sm border text-left transition-colors ${
method === "card"
? "border-primary bg-primary/5"
: "border-border hover:border-primary/50"
}`}
>
<CreditCard className="w-5 h-5 text-primary" />
<div>
<div className="font-semibold text-sm">Kort</div>
<div className="text-xs text-muted-foreground">
Automatiskt varje månad
</div>
</div>
</button>
<button
type="button"
onClick={() => setMethod("invoice")}
className={`flex items-center gap-3 p-4 rounded-sm border text-left transition-colors ${
method === "invoice"
? "border-primary bg-primary/5"
: "border-border hover:border-primary/50"
}`}
>
<FileText className="w-5 h-5 text-primary" />
<div>
<div className="font-semibold text-sm">Faktura</div>
<div className="text-xs text-muted-foreground">
Månadsvis via e-post
</div>
</div>
</button>
</div>
</div>
<div className="border border-primary/30 bg-primary/5 px-4 py-3 rounded-sm text-sm text-foreground">
{stockholmDeliveryMessage} Ange postnummer 100 00199 99 för att vidare.
</div>
<div>
<label className="block mb-1.5 text-sm font-semibold">
Företagsnamn <span className="text-primary">*</span>
</label>
<input
name="företag"
value={form.företag}
onChange={handleChange}
className="w-full px-4 py-3 rounded-sm bg-background border border-border focus:outline-none focus:ring-2 focus:ring-primary/30"
/>
{errors.företag && (
<p className="text-destructive text-xs mt-1">{errors.företag}</p>
)}
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block mb-1.5 text-sm font-semibold">
E-post <span className="text-primary">*</span>
</label>
<input
type="email"
name="epost"
value={form.epost}
onChange={handleChange}
className="w-full px-4 py-3 rounded-sm bg-background border border-border focus:outline-none focus:ring-2 focus:ring-primary/30"
/>
{errors.epost && (
<p className="text-destructive text-xs mt-1">{errors.epost}</p>
)}
</div>
<div>
<label className="block mb-1.5 text-sm font-semibold">Telefon</label>
<input
type="tel"
name="telefon"
value={form.telefon}
onChange={handleChange}
className="w-full px-4 py-3 rounded-sm bg-background border border-border focus:outline-none focus:ring-2 focus:ring-primary/30"
/>
</div>
</div>
{!user && (
<div className="relative overflow-hidden rounded-sm border border-primary/40 bg-gradient-to-br from-primary/[0.06] via-background to-primary/[0.03] p-5 md:p-6 shadow-[0_2px_20px_-8px_rgba(0,0,0,0.15)]">
<div className="absolute -top-10 -right-10 w-32 h-32 rounded-full bg-primary/10 blur-2xl pointer-events-none" />
<div className="relative flex items-start gap-3 mb-4">
<div className="flex-shrink-0 w-10 h-10 rounded-full bg-primary/15 flex items-center justify-center border border-primary/30">
<Sparkles className="w-5 h-5 text-primary" />
</div>
<div>
<div className="font-display text-lg md:text-xl font-semibold text-foreground leading-tight">
Ditt konto skapas automatiskt
</div>
<p className="text-muted-foreground text-xs md:text-sm mt-1 leading-relaxed">
Välj ett lösenord loggar vi in dig direkt. Hantera ditt abonnemang, ändra leveranser och se fakturor i "Mitt konto" när som helst.
</p>
</div>
</div>
<label className="block mb-1.5 text-sm font-semibold text-foreground">
Välj lösenord <span className="text-primary">*</span>
</label>
<div className="relative">
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground pointer-events-none" />
<input
name="password"
type={showPassword ? "text" : "password"}
value={form.password}
onChange={handleChange}
placeholder="Minst 8 tecken"
autoComplete="new-password"
aria-invalid={!!errors.password}
className="w-full pl-10 pr-11 py-3 rounded-sm bg-background border border-border focus:outline-none focus:ring-2 focus:ring-primary/40 focus:border-primary/60 transition-colors"
/>
<button
type="button"
onClick={() => setShowPassword((s) => !s)}
aria-label={showPassword ? "Dölj lösenord" : "Visa lösenord"}
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground transition-colors"
>
{showPassword ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
</button>
</div>
{errors.password ? (
<p className="text-destructive text-xs mt-1.5">{errors.password}</p>
) : (
<p className="text-muted-foreground text-xs mt-1.5">
Använd minst 8 tecken. Vi rekommenderar en blandning av bokstäver och siffror.
</p>
)}
</div>
)}
<div>
<label className="block mb-1.5 text-sm font-semibold">
Leveransadress <span className="text-primary">*</span>
</label>
<input
name="adress"
placeholder="Gatuadress, t.ex. Kungsgatan 12"
value={form.adress}
onChange={handleChange}
aria-invalid={!!errors.adress}
className="w-full px-4 py-3 rounded-sm bg-background border border-border focus:outline-none focus:ring-2 focus:ring-primary/30"
/>
{errors.adress && (
<p className="text-destructive text-xs mt-1">{errors.adress}</p>
)}
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label className="block mb-1.5 text-sm font-semibold">
Postnummer <span className="text-primary">*</span>
</label>
<input
name="postnummer"
placeholder="t.ex. 114 32"
value={form.postnummer}
onChange={handleChange}
aria-invalid={!!errors.postnummer}
className="w-full px-4 py-3 rounded-sm bg-background border border-border focus:outline-none focus:ring-2 focus:ring-primary/30"
/>
{errors.postnummer && (
<p className="text-destructive text-xs mt-1">{errors.postnummer}</p>
)}
<p className="text-muted-foreground text-xs mt-1">
Endast postnummer inom Storstockholm.
</p>
</div>
<div>
<label className="block mb-1.5 text-sm font-semibold">
Stad <span className="text-primary">*</span>
</label>
<div className="relative">
<div className="relative">
<input
name="stad"
role="combobox"
aria-expanded={cityOpen}
aria-controls="city-options"
aria-autocomplete="list"
aria-invalid={!!errors.stad}
placeholder="Sök eller välj stad..."
value={form.stad}
onFocus={() => setCityOpen(true)}
onBlur={() => window.setTimeout(() => setCityOpen(false), 120)}
onChange={(e) => {
handleChange(e);
setCityOpen(true);
}}
className="w-full px-4 py-3 pr-10 rounded-sm bg-background border border-border focus:outline-none focus:ring-2 focus:ring-primary/30"
/>
<button
type="button"
aria-label="Visa städer"
onMouseDown={(e) => e.preventDefault()}
onClick={() => setCityOpen((open) => !open)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
>
<ChevronsUpDown className="h-4 w-4" />
</button>
</div>
{cityOpen && (
<div
id="city-options"
role="listbox"
className="absolute z-50 mt-1 max-h-56 w-full overflow-auto rounded-sm border border-border bg-popover shadow-md"
>
{filteredCities.length > 0 ? (
filteredCities.map((city) => (
<button
key={city}
type="button"
role="option"
aria-selected={form.stad === city}
onMouseDown={(e) => e.preventDefault()}
onClick={() => {
setForm((f) => ({ ...f, stad: city }));
setErrors((e) => ({ ...e, stad: "" }));
setCityOpen(false);
}}
className={cn(
"flex w-full items-center gap-2 px-3 py-2 text-left text-sm hover:bg-accent hover:text-accent-foreground",
form.stad === city && "bg-accent text-accent-foreground"
)}
>
<Check className={cn("h-4 w-4", form.stad === city ? "opacity-100" : "opacity-0")} />
<span>{city}</span>
</button>
))
) : (
<div className="px-3 py-2 text-sm text-muted-foreground">
Ingen stad hittades.
</div>
)}
</div>
)}
</div>
{errors.stad && (
<p className="text-destructive text-xs mt-1">{errors.stad}</p>
)}
</div>
</div>
<div>
<label className="block mb-1.5 text-sm font-semibold">Kommentarer</label>
<textarea
name="kommentarer"
value={form.kommentarer}
onChange={handleChange}
rows={3}
className="w-full px-4 py-3 rounded-sm bg-background border border-border focus:outline-none focus:ring-2 focus:ring-primary/30"
/>
</div>
<div className="flex flex-col sm:flex-row gap-3 pt-2">
<button
type="button"
onClick={() => setShowForm(false)}
disabled={submitting}
className="sm:w-1/3 py-3 rounded-sm border border-border hover:bg-muted text-sm"
>
Tillbaka
</button>
<button
type="submit"
disabled={submitting}
className="btn-classic flex-1 py-3 rounded-sm disabled:opacity-50 flex items-center justify-center gap-2"
>
{submitting ? (
<>
<Loader2 className="w-4 h-4 animate-spin" />
{method === "card" ? "Öppnar betalning..." : "Startar abonnemang..."}
</>
) : method === "card" ? (
"Fortsätt till betalning"
) : (
"Starta abonnemang (faktura)"
)}
</button>
</div>
<p className="text-center text-muted-foreground text-xs">
Alla priser inklusive moms (25%).
</p>
</form>
)}
</div>
</div>
</section>
);
};
export default PlansSection;
-16
View File
@@ -1,16 +0,0 @@
import { EmbeddedCheckoutProvider, EmbeddedCheckout } from "@stripe/react-stripe-js";
import { getStripe } from "@/lib/stripe";
interface Props {
fetchClientSecret: () => Promise<string>;
}
export function StripeEmbeddedCheckout({ fetchClientSecret }: Props) {
return (
<div id="checkout">
<EmbeddedCheckoutProvider stripe={getStripe()} options={{ fetchClientSecret }}>
<EmbeddedCheckout />
</EmbeddedCheckoutProvider>
</div>
);
}
-69
View File
@@ -1,69 +0,0 @@
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Package, Users, CreditCard, TrendingUp } from "lucide-react";
import { useAdminStats } from "@/hooks/useAdminData";
import { Skeleton } from "@/components/ui/skeleton";
export function AdminStats() {
const { data: stats, isLoading } = useAdminStats();
if (isLoading) {
return (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
{[...Array(4)].map((_, i) => (
<Card key={i}>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<Skeleton className="h-4 w-24" />
</CardHeader>
<CardContent>
<Skeleton className="h-8 w-16" />
</CardContent>
</Card>
))}
</div>
);
}
const statCards = [
{
title: "Totala Ordrar",
value: stats?.totalOrders || 0,
icon: Package,
description: `${stats?.pendingOrders || 0} väntande`,
},
{
title: "Kunder",
value: stats?.totalCustomers || 0,
icon: Users,
description: "Registrerade kunder",
},
{
title: "Betalningar",
value: stats?.totalPayments || 0,
icon: CreditCard,
description: `${stats?.completedPayments || 0} genomförda`,
},
{
title: "Total Omsättning",
value: `${(stats?.totalRevenue || 0).toLocaleString("sv-SE")} kr`,
icon: TrendingUp,
description: "Totalt intjänat",
},
];
return (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
{statCards.map((stat) => (
<Card key={stat.title}>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">{stat.title}</CardTitle>
<stat.icon className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{stat.value}</div>
<p className="text-xs text-muted-foreground">{stat.description}</p>
</CardContent>
</Card>
))}
</div>
);
}
-95
View File
@@ -1,95 +0,0 @@
import { useState } from "react";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { Input } from "@/components/ui/input";
import { useCustomers } from "@/hooks/useAdminData";
import { Skeleton } from "@/components/ui/skeleton";
import { format } from "date-fns";
import { sv } from "date-fns/locale";
import { Search } from "lucide-react";
export function CustomersTable() {
const { data: customers, isLoading } = useCustomers();
const [search, setSearch] = useState("");
const filteredCustomers = customers?.filter((customer) => {
return (
customer.email.toLowerCase().includes(search.toLowerCase()) ||
customer.name?.toLowerCase().includes(search.toLowerCase()) ||
customer.company?.toLowerCase().includes(search.toLowerCase())
);
});
if (isLoading) {
return (
<div className="space-y-4">
<Skeleton className="h-10 w-full" />
<Skeleton className="h-64 w-full" />
</div>
);
}
return (
<div className="space-y-4">
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Sök email, namn eller företag..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="pl-9 max-w-md"
/>
</div>
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Kund</TableHead>
<TableHead>Företag</TableHead>
<TableHead>Telefon</TableHead>
<TableHead>Ordrar</TableHead>
<TableHead className="text-right">Totalt spenderat</TableHead>
<TableHead>Registrerad</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredCustomers?.length === 0 ? (
<TableRow>
<TableCell colSpan={6} className="text-center py-8 text-muted-foreground">
Inga kunder hittades
</TableCell>
</TableRow>
) : (
filteredCustomers?.map((customer) => (
<TableRow key={customer.id}>
<TableCell>
<div>
<div className="font-medium">{customer.name || "—"}</div>
<div className="text-sm text-muted-foreground">{customer.email}</div>
</div>
</TableCell>
<TableCell>{customer.company || "—"}</TableCell>
<TableCell>{customer.phone || "—"}</TableCell>
<TableCell>{customer.total_orders}</TableCell>
<TableCell className="text-right font-medium">
{Number(customer.total_spent).toLocaleString("sv-SE")} kr
</TableCell>
<TableCell>
{format(new Date(customer.created_at), "d MMM yyyy", { locale: sv })}
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
</div>
);
}
-399
View File
@@ -1,399 +0,0 @@
import { useState, useEffect } from "react";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Badge } from "@/components/ui/badge";
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Sparkles, RefreshCw, Building2, Users, MapPin, Mail, Search, Copy, Check, Send } from "lucide-react";
import { useToast } from "@/hooks/use-toast";
import { Skeleton } from "@/components/ui/skeleton";
interface Lead {
company_name: string;
industry: string;
employee_count: number;
district: string;
reason: string;
}
interface EmailContent {
subject: string;
body: string;
personalization_points: string[];
}
interface ResearchResult {
company_name: string;
research_summary: string;
email: EmailContent;
}
export function LeadsGenerator() {
const [leads, setLeads] = useState<Lead[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [researchingLead, setResearchingLead] = useState<string | null>(null);
const [emailDialog, setEmailDialog] = useState<ResearchResult | null>(null);
const [copiedField, setCopiedField] = useState<string | null>(null);
const [recipient, setRecipient] = useState("");
const [sending, setSending] = useState(false);
const { toast } = useToast();
useEffect(() => {
setRecipient("");
}, [emailDialog?.company_name]);
const sendEmail = async () => {
if (!emailDialog || !recipient) return;
setSending(true);
try {
const res = await fetch(
`${import.meta.env.VITE_SUPABASE_URL}/functions/v1/send-outreach-email`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${import.meta.env.VITE_SUPABASE_PUBLISHABLE_KEY}`,
},
body: JSON.stringify({
to_email: recipient,
to_name: emailDialog.company_name,
subject: emailDialog.email.subject,
body: emailDialog.email.body,
}),
},
);
const data = await res.json();
if (!res.ok) throw new Error(data.error || "Kunde inte skicka mejl");
toast({
title: "Mejl skickat!",
description: `Skickat från konditorivaror@gmail.com till ${recipient}`,
});
setEmailDialog(null);
} catch (err) {
toast({
title: "Fel vid utskick",
description: err instanceof Error ? err.message : "Okänt fel",
variant: "destructive",
});
} finally {
setSending(false);
}
};
const generateLeads = async () => {
setIsLoading(true);
try {
const response = await fetch(
`${import.meta.env.VITE_SUPABASE_URL}/functions/v1/generate-leads`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${import.meta.env.VITE_SUPABASE_PUBLISHABLE_KEY}`,
},
}
);
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.error || "Kunde inte generera leads");
}
const data = await response.json();
setLeads(data.leads || []);
toast({
title: "Leads genererade!",
description: `${data.leads?.length || 0} potentiella kunder hittades.`,
});
} catch (error) {
console.error("Error generating leads:", error);
toast({
title: "Fel",
description: error instanceof Error ? error.message : "Kunde inte generera leads",
variant: "destructive",
});
} finally {
setIsLoading(false);
}
};
const researchCompany = async (lead: Lead) => {
setResearchingLead(lead.company_name);
try {
const response = await fetch(
`${import.meta.env.VITE_SUPABASE_URL}/functions/v1/research-company`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${import.meta.env.VITE_SUPABASE_PUBLISHABLE_KEY}`,
},
body: JSON.stringify({
company_name: lead.company_name,
district: lead.district,
industry: lead.industry,
}),
}
);
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.error || "Kunde inte researcha företaget");
}
const data: ResearchResult = await response.json();
setEmailDialog(data);
toast({
title: "Research klar!",
description: `Personligt mejl genererat för ${lead.company_name}`,
});
} catch (error) {
console.error("Error researching company:", error);
toast({
title: "Fel",
description: error instanceof Error ? error.message : "Kunde inte researcha företaget",
variant: "destructive",
});
} finally {
setResearchingLead(null);
}
};
const copyToClipboard = async (text: string, field: string) => {
await navigator.clipboard.writeText(text);
setCopiedField(field);
setTimeout(() => setCopiedField(null), 2000);
toast({
title: "Kopierat!",
description: `${field} kopierat till urklipp`,
});
};
const getEmployeeCountBadge = (count: number) => {
if (count >= 100) return "bg-green-100 text-green-800";
if (count >= 50) return "bg-blue-100 text-blue-800";
if (count >= 25) return "bg-yellow-100 text-yellow-800";
return "bg-gray-100 text-gray-800";
};
return (
<>
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle className="flex items-center gap-2">
<Sparkles className="h-5 w-5 text-primary" />
AI Kundprospektering
</CardTitle>
<CardDescription>
Generera leads och skapa personliga mejl med AI-driven research
</CardDescription>
</div>
<Button onClick={generateLeads} disabled={isLoading}>
{isLoading ? (
<>
<RefreshCw className="h-4 w-4 mr-2 animate-spin" />
Genererar...
</>
) : (
<>
<Sparkles className="h-4 w-4 mr-2" />
Generera leads
</>
)}
</Button>
</div>
</CardHeader>
<CardContent>
{isLoading ? (
<div className="space-y-3">
{[...Array(5)].map((_, i) => (
<Skeleton key={i} className="h-16 w-full" />
))}
</div>
) : leads.length > 0 ? (
<div className="rounded-md border overflow-hidden">
<Table>
<TableHeader>
<TableRow>
<TableHead>
<div className="flex items-center gap-1">
<Building2 className="h-4 w-4" />
Företag
</div>
</TableHead>
<TableHead>Bransch</TableHead>
<TableHead>
<div className="flex items-center gap-1">
<Users className="h-4 w-4" />
Anställda
</div>
</TableHead>
<TableHead>
<div className="flex items-center gap-1">
<MapPin className="h-4 w-4" />
Stadsdel
</div>
</TableHead>
<TableHead>Varför bra kund</TableHead>
<TableHead className="text-right">Åtgärd</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{leads.map((lead, index) => (
<TableRow key={index}>
<TableCell className="font-medium">{lead.company_name}</TableCell>
<TableCell>
<Badge variant="outline">{lead.industry}</Badge>
</TableCell>
<TableCell>
<Badge className={getEmployeeCountBadge(lead.employee_count)}>
{lead.employee_count} st
</Badge>
</TableCell>
<TableCell>{lead.district}</TableCell>
<TableCell className="max-w-xs text-sm text-muted-foreground">
{lead.reason}
</TableCell>
<TableCell className="text-right">
<Button
size="sm"
variant="outline"
onClick={() => researchCompany(lead)}
disabled={researchingLead === lead.company_name}
>
{researchingLead === lead.company_name ? (
<>
<RefreshCw className="h-3 w-3 mr-1 animate-spin" />
Researchar...
</>
) : (
<>
<Search className="h-3 w-3 mr-1" />
Research & Mejl
</>
)}
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
) : (
<div className="text-center py-12 text-muted-foreground">
<Sparkles className="h-12 w-12 mx-auto mb-4 opacity-50" />
<p>Klicka "Generera leads" för att hitta potentiella kunder</p>
</div>
)}
</CardContent>
</Card>
<Dialog open={!!emailDialog} onOpenChange={() => setEmailDialog(null)}>
<DialogContent className="max-w-2xl max-h-[80vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Mail className="h-5 w-5" />
Personligt mejl för {emailDialog?.company_name}
</DialogTitle>
<DialogDescription>
AI-genererat mejl baserat företagsresearch
</DialogDescription>
</DialogHeader>
{emailDialog && (
<div className="space-y-4">
{emailDialog.research_summary && (
<div className="p-3 bg-muted rounded-lg">
<p className="text-xs font-medium text-muted-foreground mb-1">Research-sammanfattning:</p>
<p className="text-sm">{emailDialog.research_summary.substring(0, 300)}...</p>
</div>
)}
<div className="space-y-3">
<div>
<div className="flex items-center justify-between mb-1">
<label className="text-sm font-medium">Ämnesrad:</label>
<Button
size="sm"
variant="ghost"
onClick={() => copyToClipboard(emailDialog.email.subject, "Ämnesrad")}
>
{copiedField === "Ämnesrad" ? (
<Check className="h-3 w-3" />
) : (
<Copy className="h-3 w-3" />
)}
</Button>
</div>
<div className="p-3 bg-background border rounded-md">
<p className="font-medium">{emailDialog.email.subject}</p>
</div>
</div>
<div>
<div className="flex items-center justify-between mb-1">
<label className="text-sm font-medium">Mejltext:</label>
<Button
size="sm"
variant="ghost"
onClick={() => copyToClipboard(emailDialog.email.body, "Mejltext")}
>
{copiedField === "Mejltext" ? (
<Check className="h-3 w-3" />
) : (
<Copy className="h-3 w-3" />
)}
</Button>
</div>
<div className="p-3 bg-background border rounded-md whitespace-pre-wrap">
{emailDialog.email.body}
</div>
</div>
{emailDialog.email.personalization_points?.length > 0 && (
<div>
<label className="text-sm font-medium mb-2 block">Personaliseringspunkter:</label>
<ul className="list-disc list-inside text-sm text-muted-foreground space-y-1">
{emailDialog.email.personalization_points.map((point, i) => (
<li key={i}>{point}</li>
))}
</ul>
</div>
)}
<div className="pt-4 border-t space-y-2">
<label className="text-sm font-medium">Skicka till (mottagarens mejl):</label>
<div className="flex gap-2">
<Input
type="email"
placeholder="kontakt@företag.se"
value={recipient}
onChange={(e) => setRecipient(e.target.value)}
disabled={sending}
/>
<Button
onClick={sendEmail}
disabled={sending || !recipient || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(recipient)}
>
{sending ? (
<><RefreshCw className="h-4 w-4 mr-2 animate-spin" /> Skickar...</>
) : (
<><Send className="h-4 w-4 mr-2" /> Skicka mejl</>
)}
</Button>
</div>
<p className="text-xs text-muted-foreground">
Skickas från <strong>konditorivaror@gmail.com</strong> som Tiffany.
</p>
</div>
</div>
</div>
)}
</DialogContent>
</Dialog>
</>
);
}
-162
View File
@@ -1,162 +0,0 @@
import { useState } from "react";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { Badge } from "@/components/ui/badge";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { useOrders, Order } from "@/hooks/useAdminData";
import { Skeleton } from "@/components/ui/skeleton";
import { format } from "date-fns";
import { sv } from "date-fns/locale";
import { Search } from "lucide-react";
const statusColors: Record<string, string> = {
pending: "bg-yellow-500/20 text-yellow-700 border-yellow-500/30",
processing: "bg-blue-500/20 text-blue-700 border-blue-500/30",
completed: "bg-green-500/20 text-green-700 border-green-500/30",
cancelled: "bg-red-500/20 text-red-700 border-red-500/30",
};
const statusLabels: Record<string, string> = {
pending: "Väntande",
processing: "Behandlas",
completed: "Slutförd",
cancelled: "Avbruten",
};
export function OrdersTable() {
const { data: orders, isLoading } = useOrders();
const [search, setSearch] = useState("");
const [statusFilter, setStatusFilter] = useState<string>("all");
const filteredOrders = orders?.filter((order) => {
const matchesSearch =
order.order_number.toLowerCase().includes(search.toLowerCase()) ||
order.customer_email.toLowerCase().includes(search.toLowerCase()) ||
order.customer_name?.toLowerCase().includes(search.toLowerCase());
const matchesStatus = statusFilter === "all" || order.status === statusFilter;
return matchesSearch && matchesStatus;
});
if (isLoading) {
return (
<div className="space-y-4">
<Skeleton className="h-10 w-full" />
<Skeleton className="h-64 w-full" />
</div>
);
}
return (
<div className="space-y-4">
<div className="flex flex-col sm:flex-row gap-4">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Sök ordernummer, email eller namn..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="pl-9"
/>
</div>
<Select value={statusFilter} onValueChange={setStatusFilter}>
<SelectTrigger className="w-full sm:w-[180px]">
<SelectValue placeholder="Filtrera status" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Alla statusar</SelectItem>
<SelectItem value="pending">Väntande</SelectItem>
<SelectItem value="processing">Behandlas</SelectItem>
<SelectItem value="completed">Slutförd</SelectItem>
<SelectItem value="cancelled">Avbruten</SelectItem>
</SelectContent>
</Select>
</div>
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Ordernummer</TableHead>
<TableHead>Kund</TableHead>
<TableHead>Leveransadress</TableHead>
<TableHead>Datum</TableHead>
<TableHead>Status</TableHead>
<TableHead className="text-right">Belopp</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredOrders?.length === 0 ? (
<TableRow>
<TableCell colSpan={6} className="text-center py-8 text-muted-foreground">
Inga ordrar hittades
</TableCell>
</TableRow>
) : (
filteredOrders?.map((order) => {
const item = Array.isArray(order.items) ? order.items[0] : null;
const lev = item?.leverans;
const phone = item?.telefon;
const comments = item?.kommentarer;
return (
<TableRow key={order.id}>
<TableCell className="font-medium align-top">{order.order_number}</TableCell>
<TableCell className="align-top">
<div>
<div className="font-medium">{order.customer_name || "—"}</div>
<div className="text-sm text-muted-foreground">{order.customer_email}</div>
{phone && <div className="text-sm text-muted-foreground">{phone}</div>}
</div>
</TableCell>
<TableCell className="align-top">
{lev ? (
<div className="text-sm">
<div>{lev.adress || "—"}</div>
<div className="text-muted-foreground">
{[lev.postnummer, lev.stad].filter(Boolean).join(" ")}
</div>
{comments && (
<div className="text-xs text-muted-foreground mt-1 italic">
"{comments}"
</div>
)}
</div>
) : (
<span className="text-muted-foreground text-sm"></span>
)}
</TableCell>
<TableCell className="align-top">
{format(new Date(order.created_at), "d MMM yyyy, HH:mm", { locale: sv })}
</TableCell>
<TableCell className="align-top">
<Badge variant="outline" className={statusColors[order.status] || ""}>
{statusLabels[order.status] || order.status}
</Badge>
</TableCell>
<TableCell className="text-right font-medium align-top">
{Number(order.total_amount).toLocaleString("sv-SE")} {order.currency}
</TableCell>
</TableRow>
);
})
)}
</TableBody>
</Table>
</div>
</div>
);
}
-115
View File
@@ -1,115 +0,0 @@
import { useState } from "react";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { Badge } from "@/components/ui/badge";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { usePayments } from "@/hooks/useAdminData";
import { Skeleton } from "@/components/ui/skeleton";
import { format } from "date-fns";
import { sv } from "date-fns/locale";
const statusColors: Record<string, string> = {
pending: "bg-yellow-500/20 text-yellow-700 border-yellow-500/30",
completed: "bg-green-500/20 text-green-700 border-green-500/30",
failed: "bg-red-500/20 text-red-700 border-red-500/30",
refunded: "bg-purple-500/20 text-purple-700 border-purple-500/30",
};
const statusLabels: Record<string, string> = {
pending: "Väntande",
completed: "Genomförd",
failed: "Misslyckad",
refunded: "Återbetald",
};
export function PaymentsTable() {
const { data: payments, isLoading } = usePayments();
const [statusFilter, setStatusFilter] = useState<string>("all");
const filteredPayments = payments?.filter((payment) => {
return statusFilter === "all" || payment.status === statusFilter;
});
if (isLoading) {
return (
<div className="space-y-4">
<Skeleton className="h-10 w-full" />
<Skeleton className="h-64 w-full" />
</div>
);
}
return (
<div className="space-y-4">
<div className="flex">
<Select value={statusFilter} onValueChange={setStatusFilter}>
<SelectTrigger className="w-[180px]">
<SelectValue placeholder="Filtrera status" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Alla statusar</SelectItem>
<SelectItem value="pending">Väntande</SelectItem>
<SelectItem value="completed">Genomförd</SelectItem>
<SelectItem value="failed">Misslyckad</SelectItem>
<SelectItem value="refunded">Återbetald</SelectItem>
</SelectContent>
</Select>
</div>
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Betalnings-ID</TableHead>
<TableHead>Betalmetod</TableHead>
<TableHead>Datum</TableHead>
<TableHead>Status</TableHead>
<TableHead className="text-right">Belopp</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredPayments?.length === 0 ? (
<TableRow>
<TableCell colSpan={5} className="text-center py-8 text-muted-foreground">
Inga betalningar hittades
</TableCell>
</TableRow>
) : (
filteredPayments?.map((payment) => (
<TableRow key={payment.id}>
<TableCell className="font-mono text-sm">
{payment.id.slice(0, 8)}...
</TableCell>
<TableCell>{payment.payment_method || "—"}</TableCell>
<TableCell>
{format(new Date(payment.created_at), "d MMM yyyy, HH:mm", { locale: sv })}
</TableCell>
<TableCell>
<Badge variant="outline" className={statusColors[payment.status] || ""}>
{statusLabels[payment.status] || payment.status}
</Badge>
</TableCell>
<TableCell className="text-right font-medium">
{Number(payment.amount).toLocaleString("sv-SE")} {payment.currency}
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
</div>
);
}
-52
View File
@@ -1,52 +0,0 @@
import * as React from "react";
import * as AccordionPrimitive from "@radix-ui/react-accordion";
import { ChevronDown } from "lucide-react";
import { cn } from "@/lib/utils";
const Accordion = AccordionPrimitive.Root;
const AccordionItem = React.forwardRef<
React.ElementRef<typeof AccordionPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Item>
>(({ className, ...props }, ref) => (
<AccordionPrimitive.Item ref={ref} className={cn("border-b", className)} {...props} />
));
AccordionItem.displayName = "AccordionItem";
const AccordionTrigger = React.forwardRef<
React.ElementRef<typeof AccordionPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<AccordionPrimitive.Header className="flex">
<AccordionPrimitive.Trigger
ref={ref}
className={cn(
"flex flex-1 items-center justify-between py-4 font-medium transition-all hover:underline [&[data-state=open]>svg]:rotate-180",
className,
)}
{...props}
>
{children}
<ChevronDown className="h-4 w-4 shrink-0 transition-transform duration-200" />
</AccordionPrimitive.Trigger>
</AccordionPrimitive.Header>
));
AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName;
const AccordionContent = React.forwardRef<
React.ElementRef<typeof AccordionPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<AccordionPrimitive.Content
ref={ref}
className="overflow-hidden text-sm transition-all data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down"
{...props}
>
<div className={cn("pb-4 pt-0", className)}>{children}</div>
</AccordionPrimitive.Content>
));
AccordionContent.displayName = AccordionPrimitive.Content.displayName;
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent };
-104
View File
@@ -1,104 +0,0 @@
import * as React from "react";
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog";
import { cn } from "@/lib/utils";
import { buttonVariants } from "@/components/ui/button";
const AlertDialog = AlertDialogPrimitive.Root;
const AlertDialogTrigger = AlertDialogPrimitive.Trigger;
const AlertDialogPortal = AlertDialogPrimitive.Portal;
const AlertDialogOverlay = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Overlay
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className,
)}
{...props}
ref={ref}
/>
));
AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName;
const AlertDialogContent = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Content>
>(({ className, ...props }, ref) => (
<AlertDialogPortal>
<AlertDialogOverlay />
<AlertDialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
className,
)}
{...props}
/>
</AlertDialogPortal>
));
AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName;
const AlertDialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div className={cn("flex flex-col space-y-2 text-center sm:text-left", className)} {...props} />
);
AlertDialogHeader.displayName = "AlertDialogHeader";
const AlertDialogFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div className={cn("flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2", className)} {...props} />
);
AlertDialogFooter.displayName = "AlertDialogFooter";
const AlertDialogTitle = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Title ref={ref} className={cn("text-lg font-semibold", className)} {...props} />
));
AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName;
const AlertDialogDescription = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Description ref={ref} className={cn("text-sm text-muted-foreground", className)} {...props} />
));
AlertDialogDescription.displayName = AlertDialogPrimitive.Description.displayName;
const AlertDialogAction = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Action>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Action>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Action ref={ref} className={cn(buttonVariants(), className)} {...props} />
));
AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName;
const AlertDialogCancel = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Cancel>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Cancel>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Cancel
ref={ref}
className={cn(buttonVariants({ variant: "outline" }), "mt-2 sm:mt-0", className)}
{...props}
/>
));
AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName;
export {
AlertDialog,
AlertDialogPortal,
AlertDialogOverlay,
AlertDialogTrigger,
AlertDialogContent,
AlertDialogHeader,
AlertDialogFooter,
AlertDialogTitle,
AlertDialogDescription,
AlertDialogAction,
AlertDialogCancel,
};
-43
View File
@@ -1,43 +0,0 @@
import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const alertVariants = cva(
"relative w-full rounded-lg border p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground",
{
variants: {
variant: {
default: "bg-background text-foreground",
destructive: "border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive",
},
},
defaultVariants: {
variant: "default",
},
},
);
const Alert = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof alertVariants>
>(({ className, variant, ...props }, ref) => (
<div ref={ref} role="alert" className={cn(alertVariants({ variant }), className)} {...props} />
));
Alert.displayName = "Alert";
const AlertTitle = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLHeadingElement>>(
({ className, ...props }, ref) => (
<h5 ref={ref} className={cn("mb-1 font-medium leading-none tracking-tight", className)} {...props} />
),
);
AlertTitle.displayName = "AlertTitle";
const AlertDescription = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLParagraphElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn("text-sm [&_p]:leading-relaxed", className)} {...props} />
),
);
AlertDescription.displayName = "AlertDescription";
export { Alert, AlertTitle, AlertDescription };
-5
View File
@@ -1,5 +0,0 @@
import * as AspectRatioPrimitive from "@radix-ui/react-aspect-ratio";
const AspectRatio = AspectRatioPrimitive.Root;
export { AspectRatio };
-38
View File
@@ -1,38 +0,0 @@
import * as React from "react";
import * as AvatarPrimitive from "@radix-ui/react-avatar";
import { cn } from "@/lib/utils";
const Avatar = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Root>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Root
ref={ref}
className={cn("relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full", className)}
{...props}
/>
));
Avatar.displayName = AvatarPrimitive.Root.displayName;
const AvatarImage = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Image>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Image>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Image ref={ref} className={cn("aspect-square h-full w-full", className)} {...props} />
));
AvatarImage.displayName = AvatarPrimitive.Image.displayName;
const AvatarFallback = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Fallback>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Fallback>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Fallback
ref={ref}
className={cn("flex h-full w-full items-center justify-center rounded-full bg-muted", className)}
{...props}
/>
));
AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName;
export { Avatar, AvatarImage, AvatarFallback };
-29
View File
@@ -1,29 +0,0 @@
import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const badgeVariants = cva(
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
{
variants: {
variant: {
default: "border-transparent bg-primary text-primary-foreground hover:bg-primary/80",
secondary: "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
destructive: "border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80",
outline: "text-foreground",
},
},
defaultVariants: {
variant: "default",
},
},
);
export interface BadgeProps extends React.HTMLAttributes<HTMLDivElement>, VariantProps<typeof badgeVariants> {}
function Badge({ className, variant, ...props }: BadgeProps) {
return <div className={cn(badgeVariants({ variant }), className)} {...props} />;
}
export { Badge, badgeVariants };
-90
View File
@@ -1,90 +0,0 @@
import * as React from "react";
import { Slot } from "@radix-ui/react-slot";
import { ChevronRight, MoreHorizontal } from "lucide-react";
import { cn } from "@/lib/utils";
const Breadcrumb = React.forwardRef<
HTMLElement,
React.ComponentPropsWithoutRef<"nav"> & {
separator?: React.ReactNode;
}
>(({ ...props }, ref) => <nav ref={ref} aria-label="breadcrumb" {...props} />);
Breadcrumb.displayName = "Breadcrumb";
const BreadcrumbList = React.forwardRef<HTMLOListElement, React.ComponentPropsWithoutRef<"ol">>(
({ className, ...props }, ref) => (
<ol
ref={ref}
className={cn(
"flex flex-wrap items-center gap-1.5 break-words text-sm text-muted-foreground sm:gap-2.5",
className,
)}
{...props}
/>
),
);
BreadcrumbList.displayName = "BreadcrumbList";
const BreadcrumbItem = React.forwardRef<HTMLLIElement, React.ComponentPropsWithoutRef<"li">>(
({ className, ...props }, ref) => (
<li ref={ref} className={cn("inline-flex items-center gap-1.5", className)} {...props} />
),
);
BreadcrumbItem.displayName = "BreadcrumbItem";
const BreadcrumbLink = React.forwardRef<
HTMLAnchorElement,
React.ComponentPropsWithoutRef<"a"> & {
asChild?: boolean;
}
>(({ asChild, className, ...props }, ref) => {
const Comp = asChild ? Slot : "a";
return <Comp ref={ref} className={cn("transition-colors hover:text-foreground", className)} {...props} />;
});
BreadcrumbLink.displayName = "BreadcrumbLink";
const BreadcrumbPage = React.forwardRef<HTMLSpanElement, React.ComponentPropsWithoutRef<"span">>(
({ className, ...props }, ref) => (
<span
ref={ref}
role="link"
aria-disabled="true"
aria-current="page"
className={cn("font-normal text-foreground", className)}
{...props}
/>
),
);
BreadcrumbPage.displayName = "BreadcrumbPage";
const BreadcrumbSeparator = ({ children, className, ...props }: React.ComponentProps<"li">) => (
<li role="presentation" aria-hidden="true" className={cn("[&>svg]:size-3.5", className)} {...props}>
{children ?? <ChevronRight />}
</li>
);
BreadcrumbSeparator.displayName = "BreadcrumbSeparator";
const BreadcrumbEllipsis = ({ className, ...props }: React.ComponentProps<"span">) => (
<span
role="presentation"
aria-hidden="true"
className={cn("flex h-9 w-9 items-center justify-center", className)}
{...props}
>
<MoreHorizontal className="h-4 w-4" />
<span className="sr-only">More</span>
</span>
);
BreadcrumbEllipsis.displayName = "BreadcrumbElipssis";
export {
Breadcrumb,
BreadcrumbList,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbPage,
BreadcrumbSeparator,
BreadcrumbEllipsis,
};
-47
View File
@@ -1,47 +0,0 @@
import * as React from "react";
import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive: "bg-destructive text-destructive-foreground hover:bg-destructive/90",
outline: "border border-input bg-background hover:bg-accent hover:text-accent-foreground",
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-10 px-4 py-2",
sm: "h-9 rounded-md px-3",
lg: "h-11 rounded-md px-8",
icon: "h-10 w-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
},
);
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean;
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button";
return <Comp className={cn(buttonVariants({ variant, size, className }))} ref={ref} {...props} />;
},
);
Button.displayName = "Button";
export { Button, buttonVariants };
-54
View File
@@ -1,54 +0,0 @@
import * as React from "react";
import { ChevronLeft, ChevronRight } from "lucide-react";
import { DayPicker } from "react-day-picker";
import { cn } from "@/lib/utils";
import { buttonVariants } from "@/components/ui/button";
export type CalendarProps = React.ComponentProps<typeof DayPicker>;
function Calendar({ className, classNames, showOutsideDays = true, ...props }: CalendarProps) {
return (
<DayPicker
showOutsideDays={showOutsideDays}
className={cn("p-3", className)}
classNames={{
months: "flex flex-col sm:flex-row space-y-4 sm:space-x-4 sm:space-y-0",
month: "space-y-4",
caption: "flex justify-center pt-1 relative items-center",
caption_label: "text-sm font-medium",
nav: "space-x-1 flex items-center",
nav_button: cn(
buttonVariants({ variant: "outline" }),
"h-7 w-7 bg-transparent p-0 opacity-50 hover:opacity-100",
),
nav_button_previous: "absolute left-1",
nav_button_next: "absolute right-1",
table: "w-full border-collapse space-y-1",
head_row: "flex",
head_cell: "text-muted-foreground rounded-md w-9 font-normal text-[0.8rem]",
row: "flex w-full mt-2",
cell: "h-9 w-9 text-center text-sm p-0 relative [&:has([aria-selected].day-range-end)]:rounded-r-md [&:has([aria-selected].day-outside)]:bg-accent/50 [&:has([aria-selected])]:bg-accent first:[&:has([aria-selected])]:rounded-l-md last:[&:has([aria-selected])]:rounded-r-md focus-within:relative focus-within:z-20",
day: cn(buttonVariants({ variant: "ghost" }), "h-9 w-9 p-0 font-normal aria-selected:opacity-100"),
day_range_end: "day-range-end",
day_selected:
"bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground focus:bg-primary focus:text-primary-foreground",
day_today: "bg-accent text-accent-foreground",
day_outside:
"day-outside text-muted-foreground opacity-50 aria-selected:bg-accent/50 aria-selected:text-muted-foreground aria-selected:opacity-30",
day_disabled: "text-muted-foreground opacity-50",
day_range_middle: "aria-selected:bg-accent aria-selected:text-accent-foreground",
day_hidden: "invisible",
...classNames,
}}
components={{
IconLeft: ({ ..._props }) => <ChevronLeft className="h-4 w-4" />,
IconRight: ({ ..._props }) => <ChevronRight className="h-4 w-4" />,
}}
{...props}
/>
);
}
Calendar.displayName = "Calendar";
export { Calendar };
-43
View File
@@ -1,43 +0,0 @@
import * as React from "react";
import { cn } from "@/lib/utils";
const Card = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(({ className, ...props }, ref) => (
<div ref={ref} className={cn("rounded-lg border bg-card text-card-foreground shadow-sm", className)} {...props} />
));
Card.displayName = "Card";
const CardHeader = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn("flex flex-col space-y-1.5 p-6", className)} {...props} />
),
);
CardHeader.displayName = "CardHeader";
const CardTitle = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLHeadingElement>>(
({ className, ...props }, ref) => (
<h3 ref={ref} className={cn("text-2xl font-semibold leading-none tracking-tight", className)} {...props} />
),
);
CardTitle.displayName = "CardTitle";
const CardDescription = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLParagraphElement>>(
({ className, ...props }, ref) => (
<p ref={ref} className={cn("text-sm text-muted-foreground", className)} {...props} />
),
);
CardDescription.displayName = "CardDescription";
const CardContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => <div ref={ref} className={cn("p-6 pt-0", className)} {...props} />,
);
CardContent.displayName = "CardContent";
const CardFooter = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn("flex items-center p-6 pt-0", className)} {...props} />
),
);
CardFooter.displayName = "CardFooter";
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent };
-224
View File
@@ -1,224 +0,0 @@
import * as React from "react";
import useEmblaCarousel, { type UseEmblaCarouselType } from "embla-carousel-react";
import { ArrowLeft, ArrowRight } from "lucide-react";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
type CarouselApi = UseEmblaCarouselType[1];
type UseCarouselParameters = Parameters<typeof useEmblaCarousel>;
type CarouselOptions = UseCarouselParameters[0];
type CarouselPlugin = UseCarouselParameters[1];
type CarouselProps = {
opts?: CarouselOptions;
plugins?: CarouselPlugin;
orientation?: "horizontal" | "vertical";
setApi?: (api: CarouselApi) => void;
};
type CarouselContextProps = {
carouselRef: ReturnType<typeof useEmblaCarousel>[0];
api: ReturnType<typeof useEmblaCarousel>[1];
scrollPrev: () => void;
scrollNext: () => void;
canScrollPrev: boolean;
canScrollNext: boolean;
} & CarouselProps;
const CarouselContext = React.createContext<CarouselContextProps | null>(null);
function useCarousel() {
const context = React.useContext(CarouselContext);
if (!context) {
throw new Error("useCarousel must be used within a <Carousel />");
}
return context;
}
const Carousel = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement> & CarouselProps>(
({ orientation = "horizontal", opts, setApi, plugins, className, children, ...props }, ref) => {
const [carouselRef, api] = useEmblaCarousel(
{
...opts,
axis: orientation === "horizontal" ? "x" : "y",
},
plugins,
);
const [canScrollPrev, setCanScrollPrev] = React.useState(false);
const [canScrollNext, setCanScrollNext] = React.useState(false);
const onSelect = React.useCallback((api: CarouselApi) => {
if (!api) {
return;
}
setCanScrollPrev(api.canScrollPrev());
setCanScrollNext(api.canScrollNext());
}, []);
const scrollPrev = React.useCallback(() => {
api?.scrollPrev();
}, [api]);
const scrollNext = React.useCallback(() => {
api?.scrollNext();
}, [api]);
const handleKeyDown = React.useCallback(
(event: React.KeyboardEvent<HTMLDivElement>) => {
if (event.key === "ArrowLeft") {
event.preventDefault();
scrollPrev();
} else if (event.key === "ArrowRight") {
event.preventDefault();
scrollNext();
}
},
[scrollPrev, scrollNext],
);
React.useEffect(() => {
if (!api || !setApi) {
return;
}
setApi(api);
}, [api, setApi]);
React.useEffect(() => {
if (!api) {
return;
}
onSelect(api);
api.on("reInit", onSelect);
api.on("select", onSelect);
return () => {
api?.off("select", onSelect);
};
}, [api, onSelect]);
return (
<CarouselContext.Provider
value={{
carouselRef,
api: api,
opts,
orientation: orientation || (opts?.axis === "y" ? "vertical" : "horizontal"),
scrollPrev,
scrollNext,
canScrollPrev,
canScrollNext,
}}
>
<div
ref={ref}
onKeyDownCapture={handleKeyDown}
className={cn("relative", className)}
role="region"
aria-roledescription="carousel"
{...props}
>
{children}
</div>
</CarouselContext.Provider>
);
},
);
Carousel.displayName = "Carousel";
const CarouselContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => {
const { carouselRef, orientation } = useCarousel();
return (
<div ref={carouselRef} className="overflow-hidden">
<div
ref={ref}
className={cn("flex", orientation === "horizontal" ? "-ml-4" : "-mt-4 flex-col", className)}
{...props}
/>
</div>
);
},
);
CarouselContent.displayName = "CarouselContent";
const CarouselItem = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => {
const { orientation } = useCarousel();
return (
<div
ref={ref}
role="group"
aria-roledescription="slide"
className={cn("min-w-0 shrink-0 grow-0 basis-full", orientation === "horizontal" ? "pl-4" : "pt-4", className)}
{...props}
/>
);
},
);
CarouselItem.displayName = "CarouselItem";
const CarouselPrevious = React.forwardRef<HTMLButtonElement, React.ComponentProps<typeof Button>>(
({ className, variant = "outline", size = "icon", ...props }, ref) => {
const { orientation, scrollPrev, canScrollPrev } = useCarousel();
return (
<Button
ref={ref}
variant={variant}
size={size}
className={cn(
"absolute h-8 w-8 rounded-full",
orientation === "horizontal"
? "-left-12 top-1/2 -translate-y-1/2"
: "-top-12 left-1/2 -translate-x-1/2 rotate-90",
className,
)}
disabled={!canScrollPrev}
onClick={scrollPrev}
{...props}
>
<ArrowLeft className="h-4 w-4" />
<span className="sr-only">Previous slide</span>
</Button>
);
},
);
CarouselPrevious.displayName = "CarouselPrevious";
const CarouselNext = React.forwardRef<HTMLButtonElement, React.ComponentProps<typeof Button>>(
({ className, variant = "outline", size = "icon", ...props }, ref) => {
const { orientation, scrollNext, canScrollNext } = useCarousel();
return (
<Button
ref={ref}
variant={variant}
size={size}
className={cn(
"absolute h-8 w-8 rounded-full",
orientation === "horizontal"
? "-right-12 top-1/2 -translate-y-1/2"
: "-bottom-12 left-1/2 -translate-x-1/2 rotate-90",
className,
)}
disabled={!canScrollNext}
onClick={scrollNext}
{...props}
>
<ArrowRight className="h-4 w-4" />
<span className="sr-only">Next slide</span>
</Button>
);
},
);
CarouselNext.displayName = "CarouselNext";
export { type CarouselApi, Carousel, CarouselContent, CarouselItem, CarouselPrevious, CarouselNext };
-303
View File
@@ -1,303 +0,0 @@
import * as React from "react";
import * as RechartsPrimitive from "recharts";
import { cn } from "@/lib/utils";
// Format: { THEME_NAME: CSS_SELECTOR }
const THEMES = { light: "", dark: ".dark" } as const;
export type ChartConfig = {
[k in string]: {
label?: React.ReactNode;
icon?: React.ComponentType;
} & ({ color?: string; theme?: never } | { color?: never; theme: Record<keyof typeof THEMES, string> });
};
type ChartContextProps = {
config: ChartConfig;
};
const ChartContext = React.createContext<ChartContextProps | null>(null);
function useChart() {
const context = React.useContext(ChartContext);
if (!context) {
throw new Error("useChart must be used within a <ChartContainer />");
}
return context;
}
const ChartContainer = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div"> & {
config: ChartConfig;
children: React.ComponentProps<typeof RechartsPrimitive.ResponsiveContainer>["children"];
}
>(({ id, className, children, config, ...props }, ref) => {
const uniqueId = React.useId();
const chartId = `chart-${id || uniqueId.replace(/:/g, "")}`;
return (
<ChartContext.Provider value={{ config }}>
<div
data-chart={chartId}
ref={ref}
className={cn(
"flex aspect-video justify-center text-xs [&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-none [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-sector]:outline-none [&_.recharts-surface]:outline-none",
className,
)}
{...props}
>
<ChartStyle id={chartId} config={config} />
<RechartsPrimitive.ResponsiveContainer>{children}</RechartsPrimitive.ResponsiveContainer>
</div>
</ChartContext.Provider>
);
});
ChartContainer.displayName = "Chart";
const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
const colorConfig = Object.entries(config).filter(([_, config]) => config.theme || config.color);
if (!colorConfig.length) {
return null;
}
return (
<style
dangerouslySetInnerHTML={{
__html: Object.entries(THEMES)
.map(
([theme, prefix]) => `
${prefix} [data-chart=${id}] {
${colorConfig
.map(([key, itemConfig]) => {
const color = itemConfig.theme?.[theme as keyof typeof itemConfig.theme] || itemConfig.color;
return color ? ` --color-${key}: ${color};` : null;
})
.join("\n")}
}
`,
)
.join("\n"),
}}
/>
);
};
const ChartTooltip = RechartsPrimitive.Tooltip;
const ChartTooltipContent = React.forwardRef<
HTMLDivElement,
React.ComponentProps<typeof RechartsPrimitive.Tooltip> &
React.ComponentProps<"div"> & {
hideLabel?: boolean;
hideIndicator?: boolean;
indicator?: "line" | "dot" | "dashed";
nameKey?: string;
labelKey?: string;
}
>(
(
{
active,
payload,
className,
indicator = "dot",
hideLabel = false,
hideIndicator = false,
label,
labelFormatter,
labelClassName,
formatter,
color,
nameKey,
labelKey,
},
ref,
) => {
const { config } = useChart();
const tooltipLabel = React.useMemo(() => {
if (hideLabel || !payload?.length) {
return null;
}
const [item] = payload;
const key = `${labelKey || item.dataKey || item.name || "value"}`;
const itemConfig = getPayloadConfigFromPayload(config, item, key);
const value =
!labelKey && typeof label === "string"
? config[label as keyof typeof config]?.label || label
: itemConfig?.label;
if (labelFormatter) {
return <div className={cn("font-medium", labelClassName)}>{labelFormatter(value, payload)}</div>;
}
if (!value) {
return null;
}
return <div className={cn("font-medium", labelClassName)}>{value}</div>;
}, [label, labelFormatter, payload, hideLabel, labelClassName, config, labelKey]);
if (!active || !payload?.length) {
return null;
}
const nestLabel = payload.length === 1 && indicator !== "dot";
return (
<div
ref={ref}
className={cn(
"grid min-w-[8rem] items-start gap-1.5 rounded-lg border border-border/50 bg-background px-2.5 py-1.5 text-xs shadow-xl",
className,
)}
>
{!nestLabel ? tooltipLabel : null}
<div className="grid gap-1.5">
{payload.map((item, index) => {
const key = `${nameKey || item.name || item.dataKey || "value"}`;
const itemConfig = getPayloadConfigFromPayload(config, item, key);
const indicatorColor = color || item.payload.fill || item.color;
return (
<div
key={item.dataKey}
className={cn(
"flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5 [&>svg]:text-muted-foreground",
indicator === "dot" && "items-center",
)}
>
{formatter && item?.value !== undefined && item.name ? (
formatter(item.value, item.name, item, index, item.payload)
) : (
<>
{itemConfig?.icon ? (
<itemConfig.icon />
) : (
!hideIndicator && (
<div
className={cn("shrink-0 rounded-[2px] border-[--color-border] bg-[--color-bg]", {
"h-2.5 w-2.5": indicator === "dot",
"w-1": indicator === "line",
"w-0 border-[1.5px] border-dashed bg-transparent": indicator === "dashed",
"my-0.5": nestLabel && indicator === "dashed",
})}
style={
{
"--color-bg": indicatorColor,
"--color-border": indicatorColor,
} as React.CSSProperties
}
/>
)
)}
<div
className={cn(
"flex flex-1 justify-between leading-none",
nestLabel ? "items-end" : "items-center",
)}
>
<div className="grid gap-1.5">
{nestLabel ? tooltipLabel : null}
<span className="text-muted-foreground">{itemConfig?.label || item.name}</span>
</div>
{item.value && (
<span className="font-mono font-medium tabular-nums text-foreground">
{item.value.toLocaleString()}
</span>
)}
</div>
</>
)}
</div>
);
})}
</div>
</div>
);
},
);
ChartTooltipContent.displayName = "ChartTooltip";
const ChartLegend = RechartsPrimitive.Legend;
const ChartLegendContent = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div"> &
Pick<RechartsPrimitive.LegendProps, "payload" | "verticalAlign"> & {
hideIcon?: boolean;
nameKey?: string;
}
>(({ className, hideIcon = false, payload, verticalAlign = "bottom", nameKey }, ref) => {
const { config } = useChart();
if (!payload?.length) {
return null;
}
return (
<div
ref={ref}
className={cn("flex items-center justify-center gap-4", verticalAlign === "top" ? "pb-3" : "pt-3", className)}
>
{payload.map((item) => {
const key = `${nameKey || item.dataKey || "value"}`;
const itemConfig = getPayloadConfigFromPayload(config, item, key);
return (
<div
key={item.value}
className={cn("flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3 [&>svg]:text-muted-foreground")}
>
{itemConfig?.icon && !hideIcon ? (
<itemConfig.icon />
) : (
<div
className="h-2 w-2 shrink-0 rounded-[2px]"
style={{
backgroundColor: item.color,
}}
/>
)}
{itemConfig?.label}
</div>
);
})}
</div>
);
});
ChartLegendContent.displayName = "ChartLegend";
// Helper to extract item config from a payload.
function getPayloadConfigFromPayload(config: ChartConfig, payload: unknown, key: string) {
if (typeof payload !== "object" || payload === null) {
return undefined;
}
const payloadPayload =
"payload" in payload && typeof payload.payload === "object" && payload.payload !== null
? payload.payload
: undefined;
let configLabelKey: string = key;
if (key in payload && typeof payload[key as keyof typeof payload] === "string") {
configLabelKey = payload[key as keyof typeof payload] as string;
} else if (
payloadPayload &&
key in payloadPayload &&
typeof payloadPayload[key as keyof typeof payloadPayload] === "string"
) {
configLabelKey = payloadPayload[key as keyof typeof payloadPayload] as string;
}
return configLabelKey in config ? config[configLabelKey] : config[key as keyof typeof config];
}
export { ChartContainer, ChartTooltip, ChartTooltipContent, ChartLegend, ChartLegendContent, ChartStyle };
-26
View File
@@ -1,26 +0,0 @@
import * as React from "react";
import * as CheckboxPrimitive from "@radix-ui/react-checkbox";
import { Check } from "lucide-react";
import { cn } from "@/lib/utils";
const Checkbox = React.forwardRef<
React.ElementRef<typeof CheckboxPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>
>(({ className, ...props }, ref) => (
<CheckboxPrimitive.Root
ref={ref}
className={cn(
"peer h-4 w-4 shrink-0 rounded-sm border border-primary ring-offset-background data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
className,
)}
{...props}
>
<CheckboxPrimitive.Indicator className={cn("flex items-center justify-center text-current")}>
<Check className="h-4 w-4" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
));
Checkbox.displayName = CheckboxPrimitive.Root.displayName;
export { Checkbox };
-9
View File
@@ -1,9 +0,0 @@
import * as CollapsiblePrimitive from "@radix-ui/react-collapsible";
const Collapsible = CollapsiblePrimitive.Root;
const CollapsibleTrigger = CollapsiblePrimitive.CollapsibleTrigger;
const CollapsibleContent = CollapsiblePrimitive.CollapsibleContent;
export { Collapsible, CollapsibleTrigger, CollapsibleContent };
-132
View File
@@ -1,132 +0,0 @@
import * as React from "react";
import { type DialogProps } from "@radix-ui/react-dialog";
import { Command as CommandPrimitive } from "cmdk";
import { Search } from "lucide-react";
import { cn } from "@/lib/utils";
import { Dialog, DialogContent } from "@/components/ui/dialog";
const Command = React.forwardRef<
React.ElementRef<typeof CommandPrimitive>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive>
>(({ className, ...props }, ref) => (
<CommandPrimitive
ref={ref}
className={cn(
"flex h-full w-full flex-col overflow-hidden rounded-md bg-popover text-popover-foreground",
className,
)}
{...props}
/>
));
Command.displayName = CommandPrimitive.displayName;
interface CommandDialogProps extends DialogProps {}
const CommandDialog = ({ children, ...props }: CommandDialogProps) => {
return (
<Dialog {...props}>
<DialogContent className="overflow-hidden p-0 shadow-lg">
<Command className="[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-group]]:px-2 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
{children}
</Command>
</DialogContent>
</Dialog>
);
};
const CommandInput = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Input>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input>
>(({ className, ...props }, ref) => (
<div className="flex items-center border-b px-3" cmdk-input-wrapper="">
<Search className="mr-2 h-4 w-4 shrink-0 opacity-50" />
<CommandPrimitive.Input
ref={ref}
className={cn(
"flex h-11 w-full rounded-md bg-transparent py-3 text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50",
className,
)}
{...props}
/>
</div>
));
CommandInput.displayName = CommandPrimitive.Input.displayName;
const CommandList = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.List>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.List>
>(({ className, ...props }, ref) => (
<CommandPrimitive.List
ref={ref}
className={cn("max-h-[300px] overflow-y-auto overflow-x-hidden", className)}
{...props}
/>
));
CommandList.displayName = CommandPrimitive.List.displayName;
const CommandEmpty = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Empty>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Empty>
>((props, ref) => <CommandPrimitive.Empty ref={ref} className="py-6 text-center text-sm" {...props} />);
CommandEmpty.displayName = CommandPrimitive.Empty.displayName;
const CommandGroup = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Group>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Group>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Group
ref={ref}
className={cn(
"overflow-hidden p-1 text-foreground [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground",
className,
)}
{...props}
/>
));
CommandGroup.displayName = CommandPrimitive.Group.displayName;
const CommandSeparator = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Separator>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Separator ref={ref} className={cn("-mx-1 h-px bg-border", className)} {...props} />
));
CommandSeparator.displayName = CommandPrimitive.Separator.displayName;
const CommandItem = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Item>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none data-[disabled=true]:pointer-events-none data-[selected='true']:bg-accent data-[selected=true]:text-accent-foreground data-[disabled=true]:opacity-50",
className,
)}
{...props}
/>
));
CommandItem.displayName = CommandPrimitive.Item.displayName;
const CommandShortcut = ({ className, ...props }: React.HTMLAttributes<HTMLSpanElement>) => {
return <span className={cn("ml-auto text-xs tracking-widest text-muted-foreground", className)} {...props} />;
};
CommandShortcut.displayName = "CommandShortcut";
export {
Command,
CommandDialog,
CommandInput,
CommandList,
CommandEmpty,
CommandGroup,
CommandItem,
CommandShortcut,
CommandSeparator,
};
-178
View File
@@ -1,178 +0,0 @@
import * as React from "react";
import * as ContextMenuPrimitive from "@radix-ui/react-context-menu";
import { Check, ChevronRight, Circle } from "lucide-react";
import { cn } from "@/lib/utils";
const ContextMenu = ContextMenuPrimitive.Root;
const ContextMenuTrigger = ContextMenuPrimitive.Trigger;
const ContextMenuGroup = ContextMenuPrimitive.Group;
const ContextMenuPortal = ContextMenuPrimitive.Portal;
const ContextMenuSub = ContextMenuPrimitive.Sub;
const ContextMenuRadioGroup = ContextMenuPrimitive.RadioGroup;
const ContextMenuSubTrigger = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.SubTrigger>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.SubTrigger> & {
inset?: boolean;
}
>(({ className, inset, children, ...props }, ref) => (
<ContextMenuPrimitive.SubTrigger
ref={ref}
className={cn(
"flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none data-[state=open]:bg-accent data-[state=open]:text-accent-foreground focus:bg-accent focus:text-accent-foreground",
inset && "pl-8",
className,
)}
{...props}
>
{children}
<ChevronRight className="ml-auto h-4 w-4" />
</ContextMenuPrimitive.SubTrigger>
));
ContextMenuSubTrigger.displayName = ContextMenuPrimitive.SubTrigger.displayName;
const ContextMenuSubContent = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.SubContent>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.SubContent>
>(({ className, ...props }, ref) => (
<ContextMenuPrimitive.SubContent
ref={ref}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className,
)}
{...props}
/>
));
ContextMenuSubContent.displayName = ContextMenuPrimitive.SubContent.displayName;
const ContextMenuContent = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Content>
>(({ className, ...props }, ref) => (
<ContextMenuPrimitive.Portal>
<ContextMenuPrimitive.Content
ref={ref}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md animate-in fade-in-80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className,
)}
{...props}
/>
</ContextMenuPrimitive.Portal>
));
ContextMenuContent.displayName = ContextMenuPrimitive.Content.displayName;
const ContextMenuItem = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Item> & {
inset?: boolean;
}
>(({ className, inset, ...props }, ref) => (
<ContextMenuPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 focus:bg-accent focus:text-accent-foreground",
inset && "pl-8",
className,
)}
{...props}
/>
));
ContextMenuItem.displayName = ContextMenuPrimitive.Item.displayName;
const ContextMenuCheckboxItem = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.CheckboxItem>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.CheckboxItem>
>(({ className, children, checked, ...props }, ref) => (
<ContextMenuPrimitive.CheckboxItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 focus:bg-accent focus:text-accent-foreground",
className,
)}
checked={checked}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<ContextMenuPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</ContextMenuPrimitive.ItemIndicator>
</span>
{children}
</ContextMenuPrimitive.CheckboxItem>
));
ContextMenuCheckboxItem.displayName = ContextMenuPrimitive.CheckboxItem.displayName;
const ContextMenuRadioItem = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.RadioItem>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.RadioItem>
>(({ className, children, ...props }, ref) => (
<ContextMenuPrimitive.RadioItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 focus:bg-accent focus:text-accent-foreground",
className,
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<ContextMenuPrimitive.ItemIndicator>
<Circle className="h-2 w-2 fill-current" />
</ContextMenuPrimitive.ItemIndicator>
</span>
{children}
</ContextMenuPrimitive.RadioItem>
));
ContextMenuRadioItem.displayName = ContextMenuPrimitive.RadioItem.displayName;
const ContextMenuLabel = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Label> & {
inset?: boolean;
}
>(({ className, inset, ...props }, ref) => (
<ContextMenuPrimitive.Label
ref={ref}
className={cn("px-2 py-1.5 text-sm font-semibold text-foreground", inset && "pl-8", className)}
{...props}
/>
));
ContextMenuLabel.displayName = ContextMenuPrimitive.Label.displayName;
const ContextMenuSeparator = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Separator>
>(({ className, ...props }, ref) => (
<ContextMenuPrimitive.Separator ref={ref} className={cn("-mx-1 my-1 h-px bg-border", className)} {...props} />
));
ContextMenuSeparator.displayName = ContextMenuPrimitive.Separator.displayName;
const ContextMenuShortcut = ({ className, ...props }: React.HTMLAttributes<HTMLSpanElement>) => {
return <span className={cn("ml-auto text-xs tracking-widest text-muted-foreground", className)} {...props} />;
};
ContextMenuShortcut.displayName = "ContextMenuShortcut";
export {
ContextMenu,
ContextMenuTrigger,
ContextMenuContent,
ContextMenuItem,
ContextMenuCheckboxItem,
ContextMenuRadioItem,
ContextMenuLabel,
ContextMenuSeparator,
ContextMenuShortcut,
ContextMenuGroup,
ContextMenuPortal,
ContextMenuSub,
ContextMenuSubContent,
ContextMenuSubTrigger,
ContextMenuRadioGroup,
};
-95
View File
@@ -1,95 +0,0 @@
import * as React from "react";
import * as DialogPrimitive from "@radix-ui/react-dialog";
import { X } from "lucide-react";
import { cn } from "@/lib/utils";
const Dialog = DialogPrimitive.Root;
const DialogTrigger = DialogPrimitive.Trigger;
const DialogPortal = DialogPrimitive.Portal;
const DialogClose = DialogPrimitive.Close;
const DialogOverlay = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className,
)}
{...props}
/>
));
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
className,
)}
{...props}
>
{children}
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity data-[state=open]:bg-accent data-[state=open]:text-muted-foreground hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
));
DialogContent.displayName = DialogPrimitive.Content.displayName;
const DialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div className={cn("flex flex-col space-y-1.5 text-center sm:text-left", className)} {...props} />
);
DialogHeader.displayName = "DialogHeader";
const DialogFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div className={cn("flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2", className)} {...props} />
);
DialogFooter.displayName = "DialogFooter";
const DialogTitle = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Title
ref={ref}
className={cn("text-lg font-semibold leading-none tracking-tight", className)}
{...props}
/>
));
DialogTitle.displayName = DialogPrimitive.Title.displayName;
const DialogDescription = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Description ref={ref} className={cn("text-sm text-muted-foreground", className)} {...props} />
));
DialogDescription.displayName = DialogPrimitive.Description.displayName;
export {
Dialog,
DialogPortal,
DialogOverlay,
DialogClose,
DialogTrigger,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription,
};
-87
View File
@@ -1,87 +0,0 @@
import * as React from "react";
import { Drawer as DrawerPrimitive } from "vaul";
import { cn } from "@/lib/utils";
const Drawer = ({ shouldScaleBackground = true, ...props }: React.ComponentProps<typeof DrawerPrimitive.Root>) => (
<DrawerPrimitive.Root shouldScaleBackground={shouldScaleBackground} {...props} />
);
Drawer.displayName = "Drawer";
const DrawerTrigger = DrawerPrimitive.Trigger;
const DrawerPortal = DrawerPrimitive.Portal;
const DrawerClose = DrawerPrimitive.Close;
const DrawerOverlay = React.forwardRef<
React.ElementRef<typeof DrawerPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DrawerPrimitive.Overlay ref={ref} className={cn("fixed inset-0 z-50 bg-black/80", className)} {...props} />
));
DrawerOverlay.displayName = DrawerPrimitive.Overlay.displayName;
const DrawerContent = React.forwardRef<
React.ElementRef<typeof DrawerPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DrawerPortal>
<DrawerOverlay />
<DrawerPrimitive.Content
ref={ref}
className={cn(
"fixed inset-x-0 bottom-0 z-50 mt-24 flex h-auto flex-col rounded-t-[10px] border bg-background",
className,
)}
{...props}
>
<div className="mx-auto mt-4 h-2 w-[100px] rounded-full bg-muted" />
{children}
</DrawerPrimitive.Content>
</DrawerPortal>
));
DrawerContent.displayName = "DrawerContent";
const DrawerHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div className={cn("grid gap-1.5 p-4 text-center sm:text-left", className)} {...props} />
);
DrawerHeader.displayName = "DrawerHeader";
const DrawerFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div className={cn("mt-auto flex flex-col gap-2 p-4", className)} {...props} />
);
DrawerFooter.displayName = "DrawerFooter";
const DrawerTitle = React.forwardRef<
React.ElementRef<typeof DrawerPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Title>
>(({ className, ...props }, ref) => (
<DrawerPrimitive.Title
ref={ref}
className={cn("text-lg font-semibold leading-none tracking-tight", className)}
{...props}
/>
));
DrawerTitle.displayName = DrawerPrimitive.Title.displayName;
const DrawerDescription = React.forwardRef<
React.ElementRef<typeof DrawerPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Description>
>(({ className, ...props }, ref) => (
<DrawerPrimitive.Description ref={ref} className={cn("text-sm text-muted-foreground", className)} {...props} />
));
DrawerDescription.displayName = DrawerPrimitive.Description.displayName;
export {
Drawer,
DrawerPortal,
DrawerOverlay,
DrawerTrigger,
DrawerClose,
DrawerContent,
DrawerHeader,
DrawerFooter,
DrawerTitle,
DrawerDescription,
};
-179
View File
@@ -1,179 +0,0 @@
import * as React from "react";
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
import { Check, ChevronRight, Circle } from "lucide-react";
import { cn } from "@/lib/utils";
const DropdownMenu = DropdownMenuPrimitive.Root;
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger;
const DropdownMenuGroup = DropdownMenuPrimitive.Group;
const DropdownMenuPortal = DropdownMenuPrimitive.Portal;
const DropdownMenuSub = DropdownMenuPrimitive.Sub;
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup;
const DropdownMenuSubTrigger = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean;
}
>(({ className, inset, children, ...props }, ref) => (
<DropdownMenuPrimitive.SubTrigger
ref={ref}
className={cn(
"flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none data-[state=open]:bg-accent focus:bg-accent",
inset && "pl-8",
className,
)}
{...props}
>
{children}
<ChevronRight className="ml-auto h-4 w-4" />
</DropdownMenuPrimitive.SubTrigger>
));
DropdownMenuSubTrigger.displayName = DropdownMenuPrimitive.SubTrigger.displayName;
const DropdownMenuSubContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.SubContent
ref={ref}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className,
)}
{...props}
/>
));
DropdownMenuSubContent.displayName = DropdownMenuPrimitive.SubContent.displayName;
const DropdownMenuContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className,
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
));
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName;
const DropdownMenuItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean;
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors data-[disabled]:pointer-events-none data-[disabled]:opacity-50 focus:bg-accent focus:text-accent-foreground",
inset && "pl-8",
className,
)}
{...props}
/>
));
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName;
const DropdownMenuCheckboxItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
>(({ className, children, checked, ...props }, ref) => (
<DropdownMenuPrimitive.CheckboxItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors data-[disabled]:pointer-events-none data-[disabled]:opacity-50 focus:bg-accent focus:text-accent-foreground",
className,
)}
checked={checked}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
));
DropdownMenuCheckboxItem.displayName = DropdownMenuPrimitive.CheckboxItem.displayName;
const DropdownMenuRadioItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
>(({ className, children, ...props }, ref) => (
<DropdownMenuPrimitive.RadioItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors data-[disabled]:pointer-events-none data-[disabled]:opacity-50 focus:bg-accent focus:text-accent-foreground",
className,
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Circle className="h-2 w-2 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
));
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName;
const DropdownMenuLabel = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean;
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Label
ref={ref}
className={cn("px-2 py-1.5 text-sm font-semibold", inset && "pl-8", className)}
{...props}
/>
));
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName;
const DropdownMenuSeparator = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.Separator ref={ref} className={cn("-mx-1 my-1 h-px bg-muted", className)} {...props} />
));
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName;
const DropdownMenuShortcut = ({ className, ...props }: React.HTMLAttributes<HTMLSpanElement>) => {
return <span className={cn("ml-auto text-xs tracking-widest opacity-60", className)} {...props} />;
};
DropdownMenuShortcut.displayName = "DropdownMenuShortcut";
export {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuGroup,
DropdownMenuPortal,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuRadioGroup,
};
-129
View File
@@ -1,129 +0,0 @@
import * as React from "react";
import * as LabelPrimitive from "@radix-ui/react-label";
import { Slot } from "@radix-ui/react-slot";
import { Controller, ControllerProps, FieldPath, FieldValues, FormProvider, useFormContext } from "react-hook-form";
import { cn } from "@/lib/utils";
import { Label } from "@/components/ui/label";
const Form = FormProvider;
type FormFieldContextValue<
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
> = {
name: TName;
};
const FormFieldContext = React.createContext<FormFieldContextValue>({} as FormFieldContextValue);
const FormField = <
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
>({
...props
}: ControllerProps<TFieldValues, TName>) => {
return (
<FormFieldContext.Provider value={{ name: props.name }}>
<Controller {...props} />
</FormFieldContext.Provider>
);
};
const useFormField = () => {
const fieldContext = React.useContext(FormFieldContext);
const itemContext = React.useContext(FormItemContext);
const { getFieldState, formState } = useFormContext();
const fieldState = getFieldState(fieldContext.name, formState);
if (!fieldContext) {
throw new Error("useFormField should be used within <FormField>");
}
const { id } = itemContext;
return {
id,
name: fieldContext.name,
formItemId: `${id}-form-item`,
formDescriptionId: `${id}-form-item-description`,
formMessageId: `${id}-form-item-message`,
...fieldState,
};
};
type FormItemContextValue = {
id: string;
};
const FormItemContext = React.createContext<FormItemContextValue>({} as FormItemContextValue);
const FormItem = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => {
const id = React.useId();
return (
<FormItemContext.Provider value={{ id }}>
<div ref={ref} className={cn("space-y-2", className)} {...props} />
</FormItemContext.Provider>
);
},
);
FormItem.displayName = "FormItem";
const FormLabel = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root>
>(({ className, ...props }, ref) => {
const { error, formItemId } = useFormField();
return <Label ref={ref} className={cn(error && "text-destructive", className)} htmlFor={formItemId} {...props} />;
});
FormLabel.displayName = "FormLabel";
const FormControl = React.forwardRef<React.ElementRef<typeof Slot>, React.ComponentPropsWithoutRef<typeof Slot>>(
({ ...props }, ref) => {
const { error, formItemId, formDescriptionId, formMessageId } = useFormField();
return (
<Slot
ref={ref}
id={formItemId}
aria-describedby={!error ? `${formDescriptionId}` : `${formDescriptionId} ${formMessageId}`}
aria-invalid={!!error}
{...props}
/>
);
},
);
FormControl.displayName = "FormControl";
const FormDescription = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLParagraphElement>>(
({ className, ...props }, ref) => {
const { formDescriptionId } = useFormField();
return <p ref={ref} id={formDescriptionId} className={cn("text-sm text-muted-foreground", className)} {...props} />;
},
);
FormDescription.displayName = "FormDescription";
const FormMessage = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLParagraphElement>>(
({ className, children, ...props }, ref) => {
const { error, formMessageId } = useFormField();
const body = error ? String(error?.message) : children;
if (!body) {
return null;
}
return (
<p ref={ref} id={formMessageId} className={cn("text-sm font-medium text-destructive", className)} {...props}>
{body}
</p>
);
},
);
FormMessage.displayName = "FormMessage";
export { useFormField, Form, FormItem, FormLabel, FormControl, FormDescription, FormMessage, FormField };
-27
View File
@@ -1,27 +0,0 @@
import * as React from "react";
import * as HoverCardPrimitive from "@radix-ui/react-hover-card";
import { cn } from "@/lib/utils";
const HoverCard = HoverCardPrimitive.Root;
const HoverCardTrigger = HoverCardPrimitive.Trigger;
const HoverCardContent = React.forwardRef<
React.ElementRef<typeof HoverCardPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof HoverCardPrimitive.Content>
>(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
<HoverCardPrimitive.Content
ref={ref}
align={align}
sideOffset={sideOffset}
className={cn(
"z-50 w-64 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className,
)}
{...props}
/>
));
HoverCardContent.displayName = HoverCardPrimitive.Content.displayName;
export { HoverCard, HoverCardTrigger, HoverCardContent };
-61
View File
@@ -1,61 +0,0 @@
import * as React from "react";
import { OTPInput, OTPInputContext } from "input-otp";
import { Dot } from "lucide-react";
import { cn } from "@/lib/utils";
const InputOTP = React.forwardRef<React.ElementRef<typeof OTPInput>, React.ComponentPropsWithoutRef<typeof OTPInput>>(
({ className, containerClassName, ...props }, ref) => (
<OTPInput
ref={ref}
containerClassName={cn("flex items-center gap-2 has-[:disabled]:opacity-50", containerClassName)}
className={cn("disabled:cursor-not-allowed", className)}
{...props}
/>
),
);
InputOTP.displayName = "InputOTP";
const InputOTPGroup = React.forwardRef<React.ElementRef<"div">, React.ComponentPropsWithoutRef<"div">>(
({ className, ...props }, ref) => <div ref={ref} className={cn("flex items-center", className)} {...props} />,
);
InputOTPGroup.displayName = "InputOTPGroup";
const InputOTPSlot = React.forwardRef<
React.ElementRef<"div">,
React.ComponentPropsWithoutRef<"div"> & { index: number }
>(({ index, className, ...props }, ref) => {
const inputOTPContext = React.useContext(OTPInputContext);
const { char, hasFakeCaret, isActive } = inputOTPContext.slots[index];
return (
<div
ref={ref}
className={cn(
"relative flex h-10 w-10 items-center justify-center border-y border-r border-input text-sm transition-all first:rounded-l-md first:border-l last:rounded-r-md",
isActive && "z-10 ring-2 ring-ring ring-offset-background",
className,
)}
{...props}
>
{char}
{hasFakeCaret && (
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
<div className="animate-caret-blink h-4 w-px bg-foreground duration-1000" />
</div>
)}
</div>
);
});
InputOTPSlot.displayName = "InputOTPSlot";
const InputOTPSeparator = React.forwardRef<React.ElementRef<"div">, React.ComponentPropsWithoutRef<"div">>(
({ ...props }, ref) => (
<div ref={ref} role="separator" {...props}>
<Dot />
</div>
),
);
InputOTPSeparator.displayName = "InputOTPSeparator";
export { InputOTP, InputOTPGroup, InputOTPSlot, InputOTPSeparator };
-22
View File
@@ -1,22 +0,0 @@
import * as React from "react";
import { cn } from "@/lib/utils";
const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<"input">>(
({ className, type, ...props }, ref) => {
return (
<input
type={type}
className={cn(
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-base ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className,
)}
ref={ref}
{...props}
/>
);
},
);
Input.displayName = "Input";
export { Input };
-17
View File
@@ -1,17 +0,0 @@
import * as React from "react";
import * as LabelPrimitive from "@radix-ui/react-label";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const labelVariants = cva("text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70");
const Label = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> & VariantProps<typeof labelVariants>
>(({ className, ...props }, ref) => (
<LabelPrimitive.Root ref={ref} className={cn(labelVariants(), className)} {...props} />
));
Label.displayName = LabelPrimitive.Root.displayName;
export { Label };
-207
View File
@@ -1,207 +0,0 @@
import * as React from "react";
import * as MenubarPrimitive from "@radix-ui/react-menubar";
import { Check, ChevronRight, Circle } from "lucide-react";
import { cn } from "@/lib/utils";
const MenubarMenu = MenubarPrimitive.Menu;
const MenubarGroup = MenubarPrimitive.Group;
const MenubarPortal = MenubarPrimitive.Portal;
const MenubarSub = MenubarPrimitive.Sub;
const MenubarRadioGroup = MenubarPrimitive.RadioGroup;
const Menubar = React.forwardRef<
React.ElementRef<typeof MenubarPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Root>
>(({ className, ...props }, ref) => (
<MenubarPrimitive.Root
ref={ref}
className={cn("flex h-10 items-center space-x-1 rounded-md border bg-background p-1", className)}
{...props}
/>
));
Menubar.displayName = MenubarPrimitive.Root.displayName;
const MenubarTrigger = React.forwardRef<
React.ElementRef<typeof MenubarPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Trigger>
>(({ className, ...props }, ref) => (
<MenubarPrimitive.Trigger
ref={ref}
className={cn(
"flex cursor-default select-none items-center rounded-sm px-3 py-1.5 text-sm font-medium outline-none data-[state=open]:bg-accent data-[state=open]:text-accent-foreground focus:bg-accent focus:text-accent-foreground",
className,
)}
{...props}
/>
));
MenubarTrigger.displayName = MenubarPrimitive.Trigger.displayName;
const MenubarSubTrigger = React.forwardRef<
React.ElementRef<typeof MenubarPrimitive.SubTrigger>,
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.SubTrigger> & {
inset?: boolean;
}
>(({ className, inset, children, ...props }, ref) => (
<MenubarPrimitive.SubTrigger
ref={ref}
className={cn(
"flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none data-[state=open]:bg-accent data-[state=open]:text-accent-foreground focus:bg-accent focus:text-accent-foreground",
inset && "pl-8",
className,
)}
{...props}
>
{children}
<ChevronRight className="ml-auto h-4 w-4" />
</MenubarPrimitive.SubTrigger>
));
MenubarSubTrigger.displayName = MenubarPrimitive.SubTrigger.displayName;
const MenubarSubContent = React.forwardRef<
React.ElementRef<typeof MenubarPrimitive.SubContent>,
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.SubContent>
>(({ className, ...props }, ref) => (
<MenubarPrimitive.SubContent
ref={ref}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className,
)}
{...props}
/>
));
MenubarSubContent.displayName = MenubarPrimitive.SubContent.displayName;
const MenubarContent = React.forwardRef<
React.ElementRef<typeof MenubarPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Content>
>(({ className, align = "start", alignOffset = -4, sideOffset = 8, ...props }, ref) => (
<MenubarPrimitive.Portal>
<MenubarPrimitive.Content
ref={ref}
align={align}
alignOffset={alignOffset}
sideOffset={sideOffset}
className={cn(
"z-50 min-w-[12rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className,
)}
{...props}
/>
</MenubarPrimitive.Portal>
));
MenubarContent.displayName = MenubarPrimitive.Content.displayName;
const MenubarItem = React.forwardRef<
React.ElementRef<typeof MenubarPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Item> & {
inset?: boolean;
}
>(({ className, inset, ...props }, ref) => (
<MenubarPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 focus:bg-accent focus:text-accent-foreground",
inset && "pl-8",
className,
)}
{...props}
/>
));
MenubarItem.displayName = MenubarPrimitive.Item.displayName;
const MenubarCheckboxItem = React.forwardRef<
React.ElementRef<typeof MenubarPrimitive.CheckboxItem>,
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.CheckboxItem>
>(({ className, children, checked, ...props }, ref) => (
<MenubarPrimitive.CheckboxItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 focus:bg-accent focus:text-accent-foreground",
className,
)}
checked={checked}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<MenubarPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</MenubarPrimitive.ItemIndicator>
</span>
{children}
</MenubarPrimitive.CheckboxItem>
));
MenubarCheckboxItem.displayName = MenubarPrimitive.CheckboxItem.displayName;
const MenubarRadioItem = React.forwardRef<
React.ElementRef<typeof MenubarPrimitive.RadioItem>,
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.RadioItem>
>(({ className, children, ...props }, ref) => (
<MenubarPrimitive.RadioItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 focus:bg-accent focus:text-accent-foreground",
className,
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<MenubarPrimitive.ItemIndicator>
<Circle className="h-2 w-2 fill-current" />
</MenubarPrimitive.ItemIndicator>
</span>
{children}
</MenubarPrimitive.RadioItem>
));
MenubarRadioItem.displayName = MenubarPrimitive.RadioItem.displayName;
const MenubarLabel = React.forwardRef<
React.ElementRef<typeof MenubarPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Label> & {
inset?: boolean;
}
>(({ className, inset, ...props }, ref) => (
<MenubarPrimitive.Label
ref={ref}
className={cn("px-2 py-1.5 text-sm font-semibold", inset && "pl-8", className)}
{...props}
/>
));
MenubarLabel.displayName = MenubarPrimitive.Label.displayName;
const MenubarSeparator = React.forwardRef<
React.ElementRef<typeof MenubarPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Separator>
>(({ className, ...props }, ref) => (
<MenubarPrimitive.Separator ref={ref} className={cn("-mx-1 my-1 h-px bg-muted", className)} {...props} />
));
MenubarSeparator.displayName = MenubarPrimitive.Separator.displayName;
const MenubarShortcut = ({ className, ...props }: React.HTMLAttributes<HTMLSpanElement>) => {
return <span className={cn("ml-auto text-xs tracking-widest text-muted-foreground", className)} {...props} />;
};
MenubarShortcut.displayname = "MenubarShortcut";
export {
Menubar,
MenubarMenu,
MenubarTrigger,
MenubarContent,
MenubarItem,
MenubarSeparator,
MenubarLabel,
MenubarCheckboxItem,
MenubarRadioGroup,
MenubarRadioItem,
MenubarPortal,
MenubarSubContent,
MenubarSubTrigger,
MenubarGroup,
MenubarSub,
MenubarShortcut,
};
-120
View File
@@ -1,120 +0,0 @@
import * as React from "react";
import * as NavigationMenuPrimitive from "@radix-ui/react-navigation-menu";
import { cva } from "class-variance-authority";
import { ChevronDown } from "lucide-react";
import { cn } from "@/lib/utils";
const NavigationMenu = React.forwardRef<
React.ElementRef<typeof NavigationMenuPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Root>
>(({ className, children, ...props }, ref) => (
<NavigationMenuPrimitive.Root
ref={ref}
className={cn("relative z-10 flex max-w-max flex-1 items-center justify-center", className)}
{...props}
>
{children}
<NavigationMenuViewport />
</NavigationMenuPrimitive.Root>
));
NavigationMenu.displayName = NavigationMenuPrimitive.Root.displayName;
const NavigationMenuList = React.forwardRef<
React.ElementRef<typeof NavigationMenuPrimitive.List>,
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.List>
>(({ className, ...props }, ref) => (
<NavigationMenuPrimitive.List
ref={ref}
className={cn("group flex flex-1 list-none items-center justify-center space-x-1", className)}
{...props}
/>
));
NavigationMenuList.displayName = NavigationMenuPrimitive.List.displayName;
const NavigationMenuItem = NavigationMenuPrimitive.Item;
const navigationMenuTriggerStyle = cva(
"group inline-flex h-10 w-max items-center justify-center rounded-md bg-background px-4 py-2 text-sm font-medium transition-colors hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground focus:outline-none disabled:pointer-events-none disabled:opacity-50 data-[active]:bg-accent/50 data-[state=open]:bg-accent/50",
);
const NavigationMenuTrigger = React.forwardRef<
React.ElementRef<typeof NavigationMenuPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<NavigationMenuPrimitive.Trigger
ref={ref}
className={cn(navigationMenuTriggerStyle(), "group", className)}
{...props}
>
{children}{" "}
<ChevronDown
className="relative top-[1px] ml-1 h-3 w-3 transition duration-200 group-data-[state=open]:rotate-180"
aria-hidden="true"
/>
</NavigationMenuPrimitive.Trigger>
));
NavigationMenuTrigger.displayName = NavigationMenuPrimitive.Trigger.displayName;
const NavigationMenuContent = React.forwardRef<
React.ElementRef<typeof NavigationMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Content>
>(({ className, ...props }, ref) => (
<NavigationMenuPrimitive.Content
ref={ref}
className={cn(
"left-0 top-0 w-full data-[motion^=from-]:animate-in data-[motion^=to-]:animate-out data-[motion^=from-]:fade-in data-[motion^=to-]:fade-out data-[motion=from-end]:slide-in-from-right-52 data-[motion=from-start]:slide-in-from-left-52 data-[motion=to-end]:slide-out-to-right-52 data-[motion=to-start]:slide-out-to-left-52 md:absolute md:w-auto",
className,
)}
{...props}
/>
));
NavigationMenuContent.displayName = NavigationMenuPrimitive.Content.displayName;
const NavigationMenuLink = NavigationMenuPrimitive.Link;
const NavigationMenuViewport = React.forwardRef<
React.ElementRef<typeof NavigationMenuPrimitive.Viewport>,
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Viewport>
>(({ className, ...props }, ref) => (
<div className={cn("absolute left-0 top-full flex justify-center")}>
<NavigationMenuPrimitive.Viewport
className={cn(
"origin-top-center relative mt-1.5 h-[var(--radix-navigation-menu-viewport-height)] w-full overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-90 md:w-[var(--radix-navigation-menu-viewport-width)]",
className,
)}
ref={ref}
{...props}
/>
</div>
));
NavigationMenuViewport.displayName = NavigationMenuPrimitive.Viewport.displayName;
const NavigationMenuIndicator = React.forwardRef<
React.ElementRef<typeof NavigationMenuPrimitive.Indicator>,
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Indicator>
>(({ className, ...props }, ref) => (
<NavigationMenuPrimitive.Indicator
ref={ref}
className={cn(
"top-full z-[1] flex h-1.5 items-end justify-center overflow-hidden data-[state=visible]:animate-in data-[state=hidden]:animate-out data-[state=hidden]:fade-out data-[state=visible]:fade-in",
className,
)}
{...props}
>
<div className="relative top-[60%] h-2 w-2 rotate-45 rounded-tl-sm bg-border shadow-md" />
</NavigationMenuPrimitive.Indicator>
));
NavigationMenuIndicator.displayName = NavigationMenuPrimitive.Indicator.displayName;
export {
navigationMenuTriggerStyle,
NavigationMenu,
NavigationMenuList,
NavigationMenuItem,
NavigationMenuContent,
NavigationMenuTrigger,
NavigationMenuLink,
NavigationMenuIndicator,
NavigationMenuViewport,
};
-81
View File
@@ -1,81 +0,0 @@
import * as React from "react";
import { ChevronLeft, ChevronRight, MoreHorizontal } from "lucide-react";
import { cn } from "@/lib/utils";
import { ButtonProps, buttonVariants } from "@/components/ui/button";
const Pagination = ({ className, ...props }: React.ComponentProps<"nav">) => (
<nav
role="navigation"
aria-label="pagination"
className={cn("mx-auto flex w-full justify-center", className)}
{...props}
/>
);
Pagination.displayName = "Pagination";
const PaginationContent = React.forwardRef<HTMLUListElement, React.ComponentProps<"ul">>(
({ className, ...props }, ref) => (
<ul ref={ref} className={cn("flex flex-row items-center gap-1", className)} {...props} />
),
);
PaginationContent.displayName = "PaginationContent";
const PaginationItem = React.forwardRef<HTMLLIElement, React.ComponentProps<"li">>(({ className, ...props }, ref) => (
<li ref={ref} className={cn("", className)} {...props} />
));
PaginationItem.displayName = "PaginationItem";
type PaginationLinkProps = {
isActive?: boolean;
} & Pick<ButtonProps, "size"> &
React.ComponentProps<"a">;
const PaginationLink = ({ className, isActive, size = "icon", ...props }: PaginationLinkProps) => (
<a
aria-current={isActive ? "page" : undefined}
className={cn(
buttonVariants({
variant: isActive ? "outline" : "ghost",
size,
}),
className,
)}
{...props}
/>
);
PaginationLink.displayName = "PaginationLink";
const PaginationPrevious = ({ className, ...props }: React.ComponentProps<typeof PaginationLink>) => (
<PaginationLink aria-label="Go to previous page" size="default" className={cn("gap-1 pl-2.5", className)} {...props}>
<ChevronLeft className="h-4 w-4" />
<span>Previous</span>
</PaginationLink>
);
PaginationPrevious.displayName = "PaginationPrevious";
const PaginationNext = ({ className, ...props }: React.ComponentProps<typeof PaginationLink>) => (
<PaginationLink aria-label="Go to next page" size="default" className={cn("gap-1 pr-2.5", className)} {...props}>
<span>Next</span>
<ChevronRight className="h-4 w-4" />
</PaginationLink>
);
PaginationNext.displayName = "PaginationNext";
const PaginationEllipsis = ({ className, ...props }: React.ComponentProps<"span">) => (
<span aria-hidden className={cn("flex h-9 w-9 items-center justify-center", className)} {...props}>
<MoreHorizontal className="h-4 w-4" />
<span className="sr-only">More pages</span>
</span>
);
PaginationEllipsis.displayName = "PaginationEllipsis";
export {
Pagination,
PaginationContent,
PaginationEllipsis,
PaginationItem,
PaginationLink,
PaginationNext,
PaginationPrevious,
};
-29
View File
@@ -1,29 +0,0 @@
import * as React from "react";
import * as PopoverPrimitive from "@radix-ui/react-popover";
import { cn } from "@/lib/utils";
const Popover = PopoverPrimitive.Root;
const PopoverTrigger = PopoverPrimitive.Trigger;
const PopoverContent = React.forwardRef<
React.ElementRef<typeof PopoverPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
>(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
ref={ref}
align={align}
sideOffset={sideOffset}
className={cn(
"z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className,
)}
{...props}
/>
</PopoverPrimitive.Portal>
));
PopoverContent.displayName = PopoverPrimitive.Content.displayName;
export { Popover, PopoverTrigger, PopoverContent };
-23
View File
@@ -1,23 +0,0 @@
import * as React from "react";
import * as ProgressPrimitive from "@radix-ui/react-progress";
import { cn } from "@/lib/utils";
const Progress = React.forwardRef<
React.ElementRef<typeof ProgressPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ProgressPrimitive.Root>
>(({ className, value, ...props }, ref) => (
<ProgressPrimitive.Root
ref={ref}
className={cn("relative h-4 w-full overflow-hidden rounded-full bg-secondary", className)}
{...props}
>
<ProgressPrimitive.Indicator
className="h-full w-full flex-1 bg-primary transition-all"
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
/>
</ProgressPrimitive.Root>
));
Progress.displayName = ProgressPrimitive.Root.displayName;
export { Progress };
-36
View File
@@ -1,36 +0,0 @@
import * as React from "react";
import * as RadioGroupPrimitive from "@radix-ui/react-radio-group";
import { Circle } from "lucide-react";
import { cn } from "@/lib/utils";
const RadioGroup = React.forwardRef<
React.ElementRef<typeof RadioGroupPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Root>
>(({ className, ...props }, ref) => {
return <RadioGroupPrimitive.Root className={cn("grid gap-2", className)} {...props} ref={ref} />;
});
RadioGroup.displayName = RadioGroupPrimitive.Root.displayName;
const RadioGroupItem = React.forwardRef<
React.ElementRef<typeof RadioGroupPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Item>
>(({ className, ...props }, ref) => {
return (
<RadioGroupPrimitive.Item
ref={ref}
className={cn(
"aspect-square h-4 w-4 rounded-full border border-primary text-primary ring-offset-background focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
className,
)}
{...props}
>
<RadioGroupPrimitive.Indicator className="flex items-center justify-center">
<Circle className="h-2.5 w-2.5 fill-current text-current" />
</RadioGroupPrimitive.Indicator>
</RadioGroupPrimitive.Item>
);
});
RadioGroupItem.displayName = RadioGroupPrimitive.Item.displayName;
export { RadioGroup, RadioGroupItem };
-37
View File
@@ -1,37 +0,0 @@
import { GripVertical } from "lucide-react";
import * as ResizablePrimitive from "react-resizable-panels";
import { cn } from "@/lib/utils";
const ResizablePanelGroup = ({ className, ...props }: React.ComponentProps<typeof ResizablePrimitive.PanelGroup>) => (
<ResizablePrimitive.PanelGroup
className={cn("flex h-full w-full data-[panel-group-direction=vertical]:flex-col", className)}
{...props}
/>
);
const ResizablePanel = ResizablePrimitive.Panel;
const ResizableHandle = ({
withHandle,
className,
...props
}: React.ComponentProps<typeof ResizablePrimitive.PanelResizeHandle> & {
withHandle?: boolean;
}) => (
<ResizablePrimitive.PanelResizeHandle
className={cn(
"relative flex w-px items-center justify-center bg-border after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 data-[panel-group-direction=vertical]:h-px data-[panel-group-direction=vertical]:w-full data-[panel-group-direction=vertical]:after:left-0 data-[panel-group-direction=vertical]:after:h-1 data-[panel-group-direction=vertical]:after:w-full data-[panel-group-direction=vertical]:after:-translate-y-1/2 data-[panel-group-direction=vertical]:after:translate-x-0 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-offset-1 [&[data-panel-group-direction=vertical]>div]:rotate-90",
className,
)}
{...props}
>
{withHandle && (
<div className="z-10 flex h-4 w-3 items-center justify-center rounded-sm border bg-border">
<GripVertical className="h-2.5 w-2.5" />
</div>
)}
</ResizablePrimitive.PanelResizeHandle>
);
export { ResizablePanelGroup, ResizablePanel, ResizableHandle };
-38
View File
@@ -1,38 +0,0 @@
import * as React from "react";
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area";
import { cn } from "@/lib/utils";
const ScrollArea = React.forwardRef<
React.ElementRef<typeof ScrollAreaPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root>
>(({ className, children, ...props }, ref) => (
<ScrollAreaPrimitive.Root ref={ref} className={cn("relative overflow-hidden", className)} {...props}>
<ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit]">{children}</ScrollAreaPrimitive.Viewport>
<ScrollBar />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
));
ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName;
const ScrollBar = React.forwardRef<
React.ElementRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>,
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>
>(({ className, orientation = "vertical", ...props }, ref) => (
<ScrollAreaPrimitive.ScrollAreaScrollbar
ref={ref}
orientation={orientation}
className={cn(
"flex touch-none select-none transition-colors",
orientation === "vertical" && "h-full w-2.5 border-l border-l-transparent p-[1px]",
orientation === "horizontal" && "h-2.5 flex-col border-t border-t-transparent p-[1px]",
className,
)}
{...props}
>
<ScrollAreaPrimitive.ScrollAreaThumb className="relative flex-1 rounded-full bg-border" />
</ScrollAreaPrimitive.ScrollAreaScrollbar>
));
ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName;
export { ScrollArea, ScrollBar };
-143
View File
@@ -1,143 +0,0 @@
import * as React from "react";
import * as SelectPrimitive from "@radix-ui/react-select";
import { Check, ChevronDown, ChevronUp } from "lucide-react";
import { cn } from "@/lib/utils";
const Select = SelectPrimitive.Root;
const SelectGroup = SelectPrimitive.Group;
const SelectValue = SelectPrimitive.Value;
const SelectTrigger = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Trigger
ref={ref}
className={cn(
"flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
className,
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDown className="h-4 w-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
));
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName;
const SelectScrollUpButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollUpButton
ref={ref}
className={cn("flex cursor-default items-center justify-center py-1", className)}
{...props}
>
<ChevronUp className="h-4 w-4" />
</SelectPrimitive.ScrollUpButton>
));
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName;
const SelectScrollDownButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollDownButton
ref={ref}
className={cn("flex cursor-default items-center justify-center py-1", className)}
{...props}
>
<ChevronDown className="h-4 w-4" />
</SelectPrimitive.ScrollDownButton>
));
SelectScrollDownButton.displayName = SelectPrimitive.ScrollDownButton.displayName;
const SelectContent = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
>(({ className, children, position = "popper", ...props }, ref) => (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
ref={ref}
className={cn(
"relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className,
)}
position={position}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
"p-1",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]",
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
));
SelectContent.displayName = SelectPrimitive.Content.displayName;
const SelectLabel = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Label ref={ref} className={cn("py-1.5 pl-8 pr-2 text-sm font-semibold", className)} {...props} />
));
SelectLabel.displayName = SelectPrimitive.Label.displayName;
const SelectItem = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Item
ref={ref}
className={cn(
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 focus:bg-accent focus:text-accent-foreground",
className,
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
));
SelectItem.displayName = SelectPrimitive.Item.displayName;
const SelectSeparator = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Separator ref={ref} className={cn("-mx-1 my-1 h-px bg-muted", className)} {...props} />
));
SelectSeparator.displayName = SelectPrimitive.Separator.displayName;
export {
Select,
SelectGroup,
SelectValue,
SelectTrigger,
SelectContent,
SelectLabel,
SelectItem,
SelectSeparator,
SelectScrollUpButton,
SelectScrollDownButton,
};
-20
View File
@@ -1,20 +0,0 @@
import * as React from "react";
import * as SeparatorPrimitive from "@radix-ui/react-separator";
import { cn } from "@/lib/utils";
const Separator = React.forwardRef<
React.ElementRef<typeof SeparatorPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>
>(({ className, orientation = "horizontal", decorative = true, ...props }, ref) => (
<SeparatorPrimitive.Root
ref={ref}
decorative={decorative}
orientation={orientation}
className={cn("shrink-0 bg-border", orientation === "horizontal" ? "h-[1px] w-full" : "h-full w-[1px]", className)}
{...props}
/>
));
Separator.displayName = SeparatorPrimitive.Root.displayName;
export { Separator };
-107
View File
@@ -1,107 +0,0 @@
import * as SheetPrimitive from "@radix-ui/react-dialog";
import { cva, type VariantProps } from "class-variance-authority";
import { X } from "lucide-react";
import * as React from "react";
import { cn } from "@/lib/utils";
const Sheet = SheetPrimitive.Root;
const SheetTrigger = SheetPrimitive.Trigger;
const SheetClose = SheetPrimitive.Close;
const SheetPortal = SheetPrimitive.Portal;
const SheetOverlay = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<SheetPrimitive.Overlay
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className,
)}
{...props}
ref={ref}
/>
));
SheetOverlay.displayName = SheetPrimitive.Overlay.displayName;
const sheetVariants = cva(
"fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:duration-300 data-[state=open]:duration-500",
{
variants: {
side: {
top: "inset-x-0 top-0 border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top",
bottom:
"inset-x-0 bottom-0 border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom",
left: "inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm",
right:
"inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm",
},
},
defaultVariants: {
side: "right",
},
},
);
interface SheetContentProps
extends React.ComponentPropsWithoutRef<typeof SheetPrimitive.Content>,
VariantProps<typeof sheetVariants> {}
const SheetContent = React.forwardRef<React.ElementRef<typeof SheetPrimitive.Content>, SheetContentProps>(
({ side = "right", className, children, ...props }, ref) => (
<SheetPortal>
<SheetOverlay />
<SheetPrimitive.Content ref={ref} className={cn(sheetVariants({ side }), className)} {...props}>
{children}
<SheetPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity data-[state=open]:bg-secondary hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</SheetPrimitive.Close>
</SheetPrimitive.Content>
</SheetPortal>
),
);
SheetContent.displayName = SheetPrimitive.Content.displayName;
const SheetHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div className={cn("flex flex-col space-y-2 text-center sm:text-left", className)} {...props} />
);
SheetHeader.displayName = "SheetHeader";
const SheetFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div className={cn("flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2", className)} {...props} />
);
SheetFooter.displayName = "SheetFooter";
const SheetTitle = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Title>
>(({ className, ...props }, ref) => (
<SheetPrimitive.Title ref={ref} className={cn("text-lg font-semibold text-foreground", className)} {...props} />
));
SheetTitle.displayName = SheetPrimitive.Title.displayName;
const SheetDescription = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Description>
>(({ className, ...props }, ref) => (
<SheetPrimitive.Description ref={ref} className={cn("text-sm text-muted-foreground", className)} {...props} />
));
SheetDescription.displayName = SheetPrimitive.Description.displayName;
export {
Sheet,
SheetClose,
SheetContent,
SheetDescription,
SheetFooter,
SheetHeader,
SheetOverlay,
SheetPortal,
SheetTitle,
SheetTrigger,
};
-637
View File
@@ -1,637 +0,0 @@
import * as React from "react";
import { Slot } from "@radix-ui/react-slot";
import { VariantProps, cva } from "class-variance-authority";
import { PanelLeft } from "lucide-react";
import { useIsMobile } from "@/hooks/use-mobile";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Separator } from "@/components/ui/separator";
import { Sheet, SheetContent } from "@/components/ui/sheet";
import { Skeleton } from "@/components/ui/skeleton";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
const SIDEBAR_COOKIE_NAME = "sidebar:state";
const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7;
const SIDEBAR_WIDTH = "16rem";
const SIDEBAR_WIDTH_MOBILE = "18rem";
const SIDEBAR_WIDTH_ICON = "3rem";
const SIDEBAR_KEYBOARD_SHORTCUT = "b";
type SidebarContext = {
state: "expanded" | "collapsed";
open: boolean;
setOpen: (open: boolean) => void;
openMobile: boolean;
setOpenMobile: (open: boolean) => void;
isMobile: boolean;
toggleSidebar: () => void;
};
const SidebarContext = React.createContext<SidebarContext | null>(null);
function useSidebar() {
const context = React.useContext(SidebarContext);
if (!context) {
throw new Error("useSidebar must be used within a SidebarProvider.");
}
return context;
}
const SidebarProvider = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div"> & {
defaultOpen?: boolean;
open?: boolean;
onOpenChange?: (open: boolean) => void;
}
>(({ defaultOpen = true, open: openProp, onOpenChange: setOpenProp, className, style, children, ...props }, ref) => {
const isMobile = useIsMobile();
const [openMobile, setOpenMobile] = React.useState(false);
// This is the internal state of the sidebar.
// We use openProp and setOpenProp for control from outside the component.
const [_open, _setOpen] = React.useState(defaultOpen);
const open = openProp ?? _open;
const setOpen = React.useCallback(
(value: boolean | ((value: boolean) => boolean)) => {
const openState = typeof value === "function" ? value(open) : value;
if (setOpenProp) {
setOpenProp(openState);
} else {
_setOpen(openState);
}
// This sets the cookie to keep the sidebar state.
document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`;
},
[setOpenProp, open],
);
// Helper to toggle the sidebar.
const toggleSidebar = React.useCallback(() => {
return isMobile ? setOpenMobile((open) => !open) : setOpen((open) => !open);
}, [isMobile, setOpen, setOpenMobile]);
// Adds a keyboard shortcut to toggle the sidebar.
React.useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === SIDEBAR_KEYBOARD_SHORTCUT && (event.metaKey || event.ctrlKey)) {
event.preventDefault();
toggleSidebar();
}
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [toggleSidebar]);
// We add a state so that we can do data-state="expanded" or "collapsed".
// This makes it easier to style the sidebar with Tailwind classes.
const state = open ? "expanded" : "collapsed";
const contextValue = React.useMemo<SidebarContext>(
() => ({
state,
open,
setOpen,
isMobile,
openMobile,
setOpenMobile,
toggleSidebar,
}),
[state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar],
);
return (
<SidebarContext.Provider value={contextValue}>
<TooltipProvider delayDuration={0}>
<div
style={
{
"--sidebar-width": SIDEBAR_WIDTH,
"--sidebar-width-icon": SIDEBAR_WIDTH_ICON,
...style,
} as React.CSSProperties
}
className={cn("group/sidebar-wrapper flex min-h-svh w-full has-[[data-variant=inset]]:bg-sidebar", className)}
ref={ref}
{...props}
>
{children}
</div>
</TooltipProvider>
</SidebarContext.Provider>
);
});
SidebarProvider.displayName = "SidebarProvider";
const Sidebar = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div"> & {
side?: "left" | "right";
variant?: "sidebar" | "floating" | "inset";
collapsible?: "offcanvas" | "icon" | "none";
}
>(({ side = "left", variant = "sidebar", collapsible = "offcanvas", className, children, ...props }, ref) => {
const { isMobile, state, openMobile, setOpenMobile } = useSidebar();
if (collapsible === "none") {
return (
<div
className={cn("flex h-full w-[--sidebar-width] flex-col bg-sidebar text-sidebar-foreground", className)}
ref={ref}
{...props}
>
{children}
</div>
);
}
if (isMobile) {
return (
<Sheet open={openMobile} onOpenChange={setOpenMobile} {...props}>
<SheetContent
data-sidebar="sidebar"
data-mobile="true"
className="w-[--sidebar-width] bg-sidebar p-0 text-sidebar-foreground [&>button]:hidden"
style={
{
"--sidebar-width": SIDEBAR_WIDTH_MOBILE,
} as React.CSSProperties
}
side={side}
>
<div className="flex h-full w-full flex-col">{children}</div>
</SheetContent>
</Sheet>
);
}
return (
<div
ref={ref}
className="group peer hidden text-sidebar-foreground md:block"
data-state={state}
data-collapsible={state === "collapsed" ? collapsible : ""}
data-variant={variant}
data-side={side}
>
{/* This is what handles the sidebar gap on desktop */}
<div
className={cn(
"relative h-svh w-[--sidebar-width] bg-transparent transition-[width] duration-200 ease-linear",
"group-data-[collapsible=offcanvas]:w-0",
"group-data-[side=right]:rotate-180",
variant === "floating" || variant === "inset"
? "group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)_+_theme(spacing.4))]"
: "group-data-[collapsible=icon]:w-[--sidebar-width-icon]",
)}
/>
<div
className={cn(
"fixed inset-y-0 z-10 hidden h-svh w-[--sidebar-width] transition-[left,right,width] duration-200 ease-linear md:flex",
side === "left"
? "left-0 group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)]"
: "right-0 group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]",
// Adjust the padding for floating and inset variants.
variant === "floating" || variant === "inset"
? "p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)_+_theme(spacing.4)_+2px)]"
: "group-data-[collapsible=icon]:w-[--sidebar-width-icon] group-data-[side=left]:border-r group-data-[side=right]:border-l",
className,
)}
{...props}
>
<div
data-sidebar="sidebar"
className="flex h-full w-full flex-col bg-sidebar group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:border group-data-[variant=floating]:border-sidebar-border group-data-[variant=floating]:shadow"
>
{children}
</div>
</div>
</div>
);
});
Sidebar.displayName = "Sidebar";
const SidebarTrigger = React.forwardRef<React.ElementRef<typeof Button>, React.ComponentProps<typeof Button>>(
({ className, onClick, ...props }, ref) => {
const { toggleSidebar } = useSidebar();
return (
<Button
ref={ref}
data-sidebar="trigger"
variant="ghost"
size="icon"
className={cn("h-7 w-7", className)}
onClick={(event) => {
onClick?.(event);
toggleSidebar();
}}
{...props}
>
<PanelLeft />
<span className="sr-only">Toggle Sidebar</span>
</Button>
);
},
);
SidebarTrigger.displayName = "SidebarTrigger";
const SidebarRail = React.forwardRef<HTMLButtonElement, React.ComponentProps<"button">>(
({ className, ...props }, ref) => {
const { toggleSidebar } = useSidebar();
return (
<button
ref={ref}
data-sidebar="rail"
aria-label="Toggle Sidebar"
tabIndex={-1}
onClick={toggleSidebar}
title="Toggle Sidebar"
className={cn(
"absolute inset-y-0 z-20 hidden w-4 -translate-x-1/2 transition-all ease-linear after:absolute after:inset-y-0 after:left-1/2 after:w-[2px] group-data-[side=left]:-right-4 group-data-[side=right]:left-0 hover:after:bg-sidebar-border sm:flex",
"[[data-side=left]_&]:cursor-w-resize [[data-side=right]_&]:cursor-e-resize",
"[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize",
"group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full group-data-[collapsible=offcanvas]:hover:bg-sidebar",
"[[data-side=left][data-collapsible=offcanvas]_&]:-right-2",
"[[data-side=right][data-collapsible=offcanvas]_&]:-left-2",
className,
)}
{...props}
/>
);
},
);
SidebarRail.displayName = "SidebarRail";
const SidebarInset = React.forwardRef<HTMLDivElement, React.ComponentProps<"main">>(({ className, ...props }, ref) => {
return (
<main
ref={ref}
className={cn(
"relative flex min-h-svh flex-1 flex-col bg-background",
"peer-data-[variant=inset]:min-h-[calc(100svh-theme(spacing.4))] md:peer-data-[variant=inset]:m-2 md:peer-data-[state=collapsed]:peer-data-[variant=inset]:ml-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow",
className,
)}
{...props}
/>
);
});
SidebarInset.displayName = "SidebarInset";
const SidebarInput = React.forwardRef<React.ElementRef<typeof Input>, React.ComponentProps<typeof Input>>(
({ className, ...props }, ref) => {
return (
<Input
ref={ref}
data-sidebar="input"
className={cn(
"h-8 w-full bg-background shadow-none focus-visible:ring-2 focus-visible:ring-sidebar-ring",
className,
)}
{...props}
/>
);
},
);
SidebarInput.displayName = "SidebarInput";
const SidebarHeader = React.forwardRef<HTMLDivElement, React.ComponentProps<"div">>(({ className, ...props }, ref) => {
return <div ref={ref} data-sidebar="header" className={cn("flex flex-col gap-2 p-2", className)} {...props} />;
});
SidebarHeader.displayName = "SidebarHeader";
const SidebarFooter = React.forwardRef<HTMLDivElement, React.ComponentProps<"div">>(({ className, ...props }, ref) => {
return <div ref={ref} data-sidebar="footer" className={cn("flex flex-col gap-2 p-2", className)} {...props} />;
});
SidebarFooter.displayName = "SidebarFooter";
const SidebarSeparator = React.forwardRef<React.ElementRef<typeof Separator>, React.ComponentProps<typeof Separator>>(
({ className, ...props }, ref) => {
return (
<Separator
ref={ref}
data-sidebar="separator"
className={cn("mx-2 w-auto bg-sidebar-border", className)}
{...props}
/>
);
},
);
SidebarSeparator.displayName = "SidebarSeparator";
const SidebarContent = React.forwardRef<HTMLDivElement, React.ComponentProps<"div">>(({ className, ...props }, ref) => {
return (
<div
ref={ref}
data-sidebar="content"
className={cn(
"flex min-h-0 flex-1 flex-col gap-2 overflow-auto group-data-[collapsible=icon]:overflow-hidden",
className,
)}
{...props}
/>
);
});
SidebarContent.displayName = "SidebarContent";
const SidebarGroup = React.forwardRef<HTMLDivElement, React.ComponentProps<"div">>(({ className, ...props }, ref) => {
return (
<div
ref={ref}
data-sidebar="group"
className={cn("relative flex w-full min-w-0 flex-col p-2", className)}
{...props}
/>
);
});
SidebarGroup.displayName = "SidebarGroup";
const SidebarGroupLabel = React.forwardRef<HTMLDivElement, React.ComponentProps<"div"> & { asChild?: boolean }>(
({ className, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "div";
return (
<Comp
ref={ref}
data-sidebar="group-label"
className={cn(
"flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium text-sidebar-foreground/70 outline-none ring-sidebar-ring transition-[margin,opa] duration-200 ease-linear focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
"group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0",
className,
)}
{...props}
/>
);
},
);
SidebarGroupLabel.displayName = "SidebarGroupLabel";
const SidebarGroupAction = React.forwardRef<HTMLButtonElement, React.ComponentProps<"button"> & { asChild?: boolean }>(
({ className, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button";
return (
<Comp
ref={ref}
data-sidebar="group-action"
className={cn(
"absolute right-3 top-3.5 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground outline-none ring-sidebar-ring transition-transform hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
// Increases the hit area of the button on mobile.
"after:absolute after:-inset-2 after:md:hidden",
"group-data-[collapsible=icon]:hidden",
className,
)}
{...props}
/>
);
},
);
SidebarGroupAction.displayName = "SidebarGroupAction";
const SidebarGroupContent = React.forwardRef<HTMLDivElement, React.ComponentProps<"div">>(
({ className, ...props }, ref) => (
<div ref={ref} data-sidebar="group-content" className={cn("w-full text-sm", className)} {...props} />
),
);
SidebarGroupContent.displayName = "SidebarGroupContent";
const SidebarMenu = React.forwardRef<HTMLUListElement, React.ComponentProps<"ul">>(({ className, ...props }, ref) => (
<ul ref={ref} data-sidebar="menu" className={cn("flex w-full min-w-0 flex-col gap-1", className)} {...props} />
));
SidebarMenu.displayName = "SidebarMenu";
const SidebarMenuItem = React.forwardRef<HTMLLIElement, React.ComponentProps<"li">>(({ className, ...props }, ref) => (
<li ref={ref} data-sidebar="menu-item" className={cn("group/menu-item relative", className)} {...props} />
));
SidebarMenuItem.displayName = "SidebarMenuItem";
const sidebarMenuButtonVariants = cva(
"peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm outline-none ring-sidebar-ring transition-[width,height,padding] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 group-has-[[data-sidebar=menu-action]]/menu-item:pr-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:!size-8 group-data-[collapsible=icon]:!p-2 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
{
variants: {
variant: {
default: "hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
outline:
"bg-background shadow-[0_0_0_1px_hsl(var(--sidebar-border))] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_hsl(var(--sidebar-accent))]",
},
size: {
default: "h-8 text-sm",
sm: "h-7 text-xs",
lg: "h-12 text-sm group-data-[collapsible=icon]:!p-0",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
},
);
const SidebarMenuButton = React.forwardRef<
HTMLButtonElement,
React.ComponentProps<"button"> & {
asChild?: boolean;
isActive?: boolean;
tooltip?: string | React.ComponentProps<typeof TooltipContent>;
} & VariantProps<typeof sidebarMenuButtonVariants>
>(({ asChild = false, isActive = false, variant = "default", size = "default", tooltip, className, ...props }, ref) => {
const Comp = asChild ? Slot : "button";
const { isMobile, state } = useSidebar();
const button = (
<Comp
ref={ref}
data-sidebar="menu-button"
data-size={size}
data-active={isActive}
className={cn(sidebarMenuButtonVariants({ variant, size }), className)}
{...props}
/>
);
if (!tooltip) {
return button;
}
if (typeof tooltip === "string") {
tooltip = {
children: tooltip,
};
}
return (
<Tooltip>
<TooltipTrigger asChild>{button}</TooltipTrigger>
<TooltipContent side="right" align="center" hidden={state !== "collapsed" || isMobile} {...tooltip} />
</Tooltip>
);
});
SidebarMenuButton.displayName = "SidebarMenuButton";
const SidebarMenuAction = React.forwardRef<
HTMLButtonElement,
React.ComponentProps<"button"> & {
asChild?: boolean;
showOnHover?: boolean;
}
>(({ className, asChild = false, showOnHover = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button";
return (
<Comp
ref={ref}
data-sidebar="menu-action"
className={cn(
"absolute right-1 top-1.5 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground outline-none ring-sidebar-ring transition-transform peer-hover/menu-button:text-sidebar-accent-foreground hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
// Increases the hit area of the button on mobile.
"after:absolute after:-inset-2 after:md:hidden",
"peer-data-[size=sm]/menu-button:top-1",
"peer-data-[size=default]/menu-button:top-1.5",
"peer-data-[size=lg]/menu-button:top-2.5",
"group-data-[collapsible=icon]:hidden",
showOnHover &&
"group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 peer-data-[active=true]/menu-button:text-sidebar-accent-foreground md:opacity-0",
className,
)}
{...props}
/>
);
});
SidebarMenuAction.displayName = "SidebarMenuAction";
const SidebarMenuBadge = React.forwardRef<HTMLDivElement, React.ComponentProps<"div">>(
({ className, ...props }, ref) => (
<div
ref={ref}
data-sidebar="menu-badge"
className={cn(
"pointer-events-none absolute right-1 flex h-5 min-w-5 select-none items-center justify-center rounded-md px-1 text-xs font-medium tabular-nums text-sidebar-foreground",
"peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[active=true]/menu-button:text-sidebar-accent-foreground",
"peer-data-[size=sm]/menu-button:top-1",
"peer-data-[size=default]/menu-button:top-1.5",
"peer-data-[size=lg]/menu-button:top-2.5",
"group-data-[collapsible=icon]:hidden",
className,
)}
{...props}
/>
),
);
SidebarMenuBadge.displayName = "SidebarMenuBadge";
const SidebarMenuSkeleton = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div"> & {
showIcon?: boolean;
}
>(({ className, showIcon = false, ...props }, ref) => {
// Random width between 50 to 90%.
const width = React.useMemo(() => {
return `${Math.floor(Math.random() * 40) + 50}%`;
}, []);
return (
<div
ref={ref}
data-sidebar="menu-skeleton"
className={cn("flex h-8 items-center gap-2 rounded-md px-2", className)}
{...props}
>
{showIcon && <Skeleton className="size-4 rounded-md" data-sidebar="menu-skeleton-icon" />}
<Skeleton
className="h-4 max-w-[--skeleton-width] flex-1"
data-sidebar="menu-skeleton-text"
style={
{
"--skeleton-width": width,
} as React.CSSProperties
}
/>
</div>
);
});
SidebarMenuSkeleton.displayName = "SidebarMenuSkeleton";
const SidebarMenuSub = React.forwardRef<HTMLUListElement, React.ComponentProps<"ul">>(
({ className, ...props }, ref) => (
<ul
ref={ref}
data-sidebar="menu-sub"
className={cn(
"mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-l border-sidebar-border px-2.5 py-0.5",
"group-data-[collapsible=icon]:hidden",
className,
)}
{...props}
/>
),
);
SidebarMenuSub.displayName = "SidebarMenuSub";
const SidebarMenuSubItem = React.forwardRef<HTMLLIElement, React.ComponentProps<"li">>(({ ...props }, ref) => (
<li ref={ref} {...props} />
));
SidebarMenuSubItem.displayName = "SidebarMenuSubItem";
const SidebarMenuSubButton = React.forwardRef<
HTMLAnchorElement,
React.ComponentProps<"a"> & {
asChild?: boolean;
size?: "sm" | "md";
isActive?: boolean;
}
>(({ asChild = false, size = "md", isActive, className, ...props }, ref) => {
const Comp = asChild ? Slot : "a";
return (
<Comp
ref={ref}
data-sidebar="menu-sub-button"
data-size={size}
data-active={isActive}
className={cn(
"flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 text-sidebar-foreground outline-none ring-sidebar-ring aria-disabled:pointer-events-none aria-disabled:opacity-50 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0 [&>svg]:text-sidebar-accent-foreground",
"data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground",
size === "sm" && "text-xs",
size === "md" && "text-sm",
"group-data-[collapsible=icon]:hidden",
className,
)}
{...props}
/>
);
});
SidebarMenuSubButton.displayName = "SidebarMenuSubButton";
export {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarGroup,
SidebarGroupAction,
SidebarGroupContent,
SidebarGroupLabel,
SidebarHeader,
SidebarInput,
SidebarInset,
SidebarMenu,
SidebarMenuAction,
SidebarMenuBadge,
SidebarMenuButton,
SidebarMenuItem,
SidebarMenuSkeleton,
SidebarMenuSub,
SidebarMenuSubButton,
SidebarMenuSubItem,
SidebarProvider,
SidebarRail,
SidebarSeparator,
SidebarTrigger,
useSidebar,
};
-7
View File
@@ -1,7 +0,0 @@
import { cn } from "@/lib/utils";
function Skeleton({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
return <div className={cn("animate-pulse rounded-md bg-muted", className)} {...props} />;
}
export { Skeleton };
-23
View File
@@ -1,23 +0,0 @@
import * as React from "react";
import * as SliderPrimitive from "@radix-ui/react-slider";
import { cn } from "@/lib/utils";
const Slider = React.forwardRef<
React.ElementRef<typeof SliderPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof SliderPrimitive.Root>
>(({ className, ...props }, ref) => (
<SliderPrimitive.Root
ref={ref}
className={cn("relative flex w-full touch-none select-none items-center", className)}
{...props}
>
<SliderPrimitive.Track className="relative h-2 w-full grow overflow-hidden rounded-full bg-secondary">
<SliderPrimitive.Range className="absolute h-full bg-primary" />
</SliderPrimitive.Track>
<SliderPrimitive.Thumb className="block h-5 w-5 rounded-full border-2 border-primary bg-background ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50" />
</SliderPrimitive.Root>
));
Slider.displayName = SliderPrimitive.Root.displayName;
export { Slider };
-27
View File
@@ -1,27 +0,0 @@
import { useTheme } from "next-themes";
import { Toaster as Sonner, toast } from "sonner";
type ToasterProps = React.ComponentProps<typeof Sonner>;
const Toaster = ({ ...props }: ToasterProps) => {
const { theme = "system" } = useTheme();
return (
<Sonner
theme={theme as ToasterProps["theme"]}
className="toaster group"
toastOptions={{
classNames: {
toast:
"group toast group-[.toaster]:bg-background group-[.toaster]:text-foreground group-[.toaster]:border-border group-[.toaster]:shadow-lg",
description: "group-[.toast]:text-muted-foreground",
actionButton: "group-[.toast]:bg-primary group-[.toast]:text-primary-foreground",
cancelButton: "group-[.toast]:bg-muted group-[.toast]:text-muted-foreground",
},
}}
{...props}
/>
);
};
export { Toaster, toast };
-27
View File
@@ -1,27 +0,0 @@
import * as React from "react";
import * as SwitchPrimitives from "@radix-ui/react-switch";
import { cn } from "@/lib/utils";
const Switch = React.forwardRef<
React.ElementRef<typeof SwitchPrimitives.Root>,
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
>(({ className, ...props }, ref) => (
<SwitchPrimitives.Root
className={cn(
"peer inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors data-[state=checked]:bg-primary data-[state=unchecked]:bg-input focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50",
className,
)}
{...props}
ref={ref}
>
<SwitchPrimitives.Thumb
className={cn(
"pointer-events-none block h-5 w-5 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-5 data-[state=unchecked]:translate-x-0",
)}
/>
</SwitchPrimitives.Root>
));
Switch.displayName = SwitchPrimitives.Root.displayName;
export { Switch };
-72
View File
@@ -1,72 +0,0 @@
import * as React from "react";
import { cn } from "@/lib/utils";
const Table = React.forwardRef<HTMLTableElement, React.HTMLAttributes<HTMLTableElement>>(
({ className, ...props }, ref) => (
<div className="relative w-full overflow-auto">
<table ref={ref} className={cn("w-full caption-bottom text-sm", className)} {...props} />
</div>
),
);
Table.displayName = "Table";
const TableHeader = React.forwardRef<HTMLTableSectionElement, React.HTMLAttributes<HTMLTableSectionElement>>(
({ className, ...props }, ref) => <thead ref={ref} className={cn("[&_tr]:border-b", className)} {...props} />,
);
TableHeader.displayName = "TableHeader";
const TableBody = React.forwardRef<HTMLTableSectionElement, React.HTMLAttributes<HTMLTableSectionElement>>(
({ className, ...props }, ref) => (
<tbody ref={ref} className={cn("[&_tr:last-child]:border-0", className)} {...props} />
),
);
TableBody.displayName = "TableBody";
const TableFooter = React.forwardRef<HTMLTableSectionElement, React.HTMLAttributes<HTMLTableSectionElement>>(
({ className, ...props }, ref) => (
<tfoot ref={ref} className={cn("border-t bg-muted/50 font-medium [&>tr]:last:border-b-0", className)} {...props} />
),
);
TableFooter.displayName = "TableFooter";
const TableRow = React.forwardRef<HTMLTableRowElement, React.HTMLAttributes<HTMLTableRowElement>>(
({ className, ...props }, ref) => (
<tr
ref={ref}
className={cn("border-b transition-colors data-[state=selected]:bg-muted hover:bg-muted/50", className)}
{...props}
/>
),
);
TableRow.displayName = "TableRow";
const TableHead = React.forwardRef<HTMLTableCellElement, React.ThHTMLAttributes<HTMLTableCellElement>>(
({ className, ...props }, ref) => (
<th
ref={ref}
className={cn(
"h-12 px-4 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0",
className,
)}
{...props}
/>
),
);
TableHead.displayName = "TableHead";
const TableCell = React.forwardRef<HTMLTableCellElement, React.TdHTMLAttributes<HTMLTableCellElement>>(
({ className, ...props }, ref) => (
<td ref={ref} className={cn("p-4 align-middle [&:has([role=checkbox])]:pr-0", className)} {...props} />
),
);
TableCell.displayName = "TableCell";
const TableCaption = React.forwardRef<HTMLTableCaptionElement, React.HTMLAttributes<HTMLTableCaptionElement>>(
({ className, ...props }, ref) => (
<caption ref={ref} className={cn("mt-4 text-sm text-muted-foreground", className)} {...props} />
),
);
TableCaption.displayName = "TableCaption";
export { Table, TableHeader, TableBody, TableFooter, TableHead, TableRow, TableCell, TableCaption };
-53
View File
@@ -1,53 +0,0 @@
import * as React from "react";
import * as TabsPrimitive from "@radix-ui/react-tabs";
import { cn } from "@/lib/utils";
const Tabs = TabsPrimitive.Root;
const TabsList = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.List>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
>(({ className, ...props }, ref) => (
<TabsPrimitive.List
ref={ref}
className={cn(
"inline-flex h-10 items-center justify-center rounded-md bg-muted p-1 text-muted-foreground",
className,
)}
{...props}
/>
));
TabsList.displayName = TabsPrimitive.List.displayName;
const TabsTrigger = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Trigger
ref={ref}
className={cn(
"inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 text-sm font-medium ring-offset-background transition-all data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
className,
)}
{...props}
/>
));
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName;
const TabsContent = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Content
ref={ref}
className={cn(
"mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
className,
)}
{...props}
/>
));
TabsContent.displayName = TabsPrimitive.Content.displayName;
export { Tabs, TabsList, TabsTrigger, TabsContent };
-21
View File
@@ -1,21 +0,0 @@
import * as React from "react";
import { cn } from "@/lib/utils";
export interface TextareaProps extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {}
const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(({ className, ...props }, ref) => {
return (
<textarea
className={cn(
"flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
className,
)}
ref={ref}
{...props}
/>
);
});
Textarea.displayName = "Textarea";
export { Textarea };
-111
View File
@@ -1,111 +0,0 @@
import * as React from "react";
import * as ToastPrimitives from "@radix-ui/react-toast";
import { cva, type VariantProps } from "class-variance-authority";
import { X } from "lucide-react";
import { cn } from "@/lib/utils";
const ToastProvider = ToastPrimitives.Provider;
const ToastViewport = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Viewport>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Viewport>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Viewport
ref={ref}
className={cn(
"fixed top-0 z-[100] flex max-h-screen w-full flex-col-reverse p-4 sm:bottom-0 sm:right-0 sm:top-auto sm:flex-col md:max-w-[420px]",
className,
)}
{...props}
/>
));
ToastViewport.displayName = ToastPrimitives.Viewport.displayName;
const toastVariants = cva(
"group pointer-events-auto relative flex w-full items-center justify-between space-x-4 overflow-hidden rounded-md border p-6 pr-8 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full",
{
variants: {
variant: {
default: "border bg-background text-foreground",
destructive: "destructive group border-destructive bg-destructive text-destructive-foreground",
},
},
defaultVariants: {
variant: "default",
},
},
);
const Toast = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Root>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Root> & VariantProps<typeof toastVariants>
>(({ className, variant, ...props }, ref) => {
return <ToastPrimitives.Root ref={ref} className={cn(toastVariants({ variant }), className)} {...props} />;
});
Toast.displayName = ToastPrimitives.Root.displayName;
const ToastAction = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Action>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Action>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Action
ref={ref}
className={cn(
"inline-flex h-8 shrink-0 items-center justify-center rounded-md border bg-transparent px-3 text-sm font-medium ring-offset-background transition-colors group-[.destructive]:border-muted/40 hover:bg-secondary group-[.destructive]:hover:border-destructive/30 group-[.destructive]:hover:bg-destructive group-[.destructive]:hover:text-destructive-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 group-[.destructive]:focus:ring-destructive disabled:pointer-events-none disabled:opacity-50",
className,
)}
{...props}
/>
));
ToastAction.displayName = ToastPrimitives.Action.displayName;
const ToastClose = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Close>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Close>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Close
ref={ref}
className={cn(
"absolute right-2 top-2 rounded-md p-1 text-foreground/50 opacity-0 transition-opacity group-hover:opacity-100 group-[.destructive]:text-red-300 hover:text-foreground group-[.destructive]:hover:text-red-50 focus:opacity-100 focus:outline-none focus:ring-2 group-[.destructive]:focus:ring-red-400 group-[.destructive]:focus:ring-offset-red-600",
className,
)}
toast-close=""
{...props}
>
<X className="h-4 w-4" />
</ToastPrimitives.Close>
));
ToastClose.displayName = ToastPrimitives.Close.displayName;
const ToastTitle = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Title>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Title>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Title ref={ref} className={cn("text-sm font-semibold", className)} {...props} />
));
ToastTitle.displayName = ToastPrimitives.Title.displayName;
const ToastDescription = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Description>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Description>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Description ref={ref} className={cn("text-sm opacity-90", className)} {...props} />
));
ToastDescription.displayName = ToastPrimitives.Description.displayName;
type ToastProps = React.ComponentPropsWithoutRef<typeof Toast>;
type ToastActionElement = React.ReactElement<typeof ToastAction>;
export {
type ToastProps,
type ToastActionElement,
ToastProvider,
ToastViewport,
Toast,
ToastTitle,
ToastDescription,
ToastClose,
ToastAction,
};
-24
View File
@@ -1,24 +0,0 @@
import { useToast } from "@/hooks/use-toast";
import { Toast, ToastClose, ToastDescription, ToastProvider, ToastTitle, ToastViewport } from "@/components/ui/toast";
export function Toaster() {
const { toasts } = useToast();
return (
<ToastProvider>
{toasts.map(function ({ id, title, description, action, ...props }) {
return (
<Toast key={id} {...props}>
<div className="grid gap-1">
{title && <ToastTitle>{title}</ToastTitle>}
{description && <ToastDescription>{description}</ToastDescription>}
</div>
{action}
<ToastClose />
</Toast>
);
})}
<ToastViewport />
</ToastProvider>
);
}
-49
View File
@@ -1,49 +0,0 @@
import * as React from "react";
import * as ToggleGroupPrimitive from "@radix-ui/react-toggle-group";
import { type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
import { toggleVariants } from "@/components/ui/toggle";
const ToggleGroupContext = React.createContext<VariantProps<typeof toggleVariants>>({
size: "default",
variant: "default",
});
const ToggleGroup = React.forwardRef<
React.ElementRef<typeof ToggleGroupPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ToggleGroupPrimitive.Root> & VariantProps<typeof toggleVariants>
>(({ className, variant, size, children, ...props }, ref) => (
<ToggleGroupPrimitive.Root ref={ref} className={cn("flex items-center justify-center gap-1", className)} {...props}>
<ToggleGroupContext.Provider value={{ variant, size }}>{children}</ToggleGroupContext.Provider>
</ToggleGroupPrimitive.Root>
));
ToggleGroup.displayName = ToggleGroupPrimitive.Root.displayName;
const ToggleGroupItem = React.forwardRef<
React.ElementRef<typeof ToggleGroupPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof ToggleGroupPrimitive.Item> & VariantProps<typeof toggleVariants>
>(({ className, children, variant, size, ...props }, ref) => {
const context = React.useContext(ToggleGroupContext);
return (
<ToggleGroupPrimitive.Item
ref={ref}
className={cn(
toggleVariants({
variant: context.variant || variant,
size: context.size || size,
}),
className,
)}
{...props}
>
{children}
</ToggleGroupPrimitive.Item>
);
});
ToggleGroupItem.displayName = ToggleGroupPrimitive.Item.displayName;
export { ToggleGroup, ToggleGroupItem };
-37
View File
@@ -1,37 +0,0 @@
import * as React from "react";
import * as TogglePrimitive from "@radix-ui/react-toggle";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const toggleVariants = cva(
"inline-flex items-center justify-center rounded-md text-sm font-medium ring-offset-background transition-colors hover:bg-muted hover:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=on]:bg-accent data-[state=on]:text-accent-foreground",
{
variants: {
variant: {
default: "bg-transparent",
outline: "border border-input bg-transparent hover:bg-accent hover:text-accent-foreground",
},
size: {
default: "h-10 px-3",
sm: "h-9 px-2.5",
lg: "h-11 px-5",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
},
);
const Toggle = React.forwardRef<
React.ElementRef<typeof TogglePrimitive.Root>,
React.ComponentPropsWithoutRef<typeof TogglePrimitive.Root> & VariantProps<typeof toggleVariants>
>(({ className, variant, size, ...props }, ref) => (
<TogglePrimitive.Root ref={ref} className={cn(toggleVariants({ variant, size, className }))} {...props} />
));
Toggle.displayName = TogglePrimitive.Root.displayName;
export { Toggle, toggleVariants };
-28
View File
@@ -1,28 +0,0 @@
import * as React from "react";
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
import { cn } from "@/lib/utils";
const TooltipProvider = TooltipPrimitive.Provider;
const Tooltip = TooltipPrimitive.Root;
const TooltipTrigger = TooltipPrimitive.Trigger;
const TooltipContent = React.forwardRef<
React.ElementRef<typeof TooltipPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<TooltipPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
"z-50 overflow-hidden rounded-md border bg-popover px-3 py-1.5 text-sm text-popover-foreground shadow-md animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className,
)}
{...props}
/>
));
TooltipContent.displayName = TooltipPrimitive.Content.displayName;
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };
-3
View File
@@ -1,3 +0,0 @@
import { useToast, toast } from "@/hooks/use-toast";
export { useToast, toast };
-19
View File
@@ -1,19 +0,0 @@
import * as React from "react";
const MOBILE_BREAKPOINT = 768;
export function useIsMobile() {
const [isMobile, setIsMobile] = React.useState<boolean | undefined>(undefined);
React.useEffect(() => {
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`);
const onChange = () => {
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
};
mql.addEventListener("change", onChange);
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
return () => mql.removeEventListener("change", onChange);
}, []);
return !!isMobile;
}
-186
View File
@@ -1,186 +0,0 @@
import * as React from "react";
import type { ToastActionElement, ToastProps } from "@/components/ui/toast";
const TOAST_LIMIT = 1;
const TOAST_REMOVE_DELAY = 1000000;
type ToasterToast = ToastProps & {
id: string;
title?: React.ReactNode;
description?: React.ReactNode;
action?: ToastActionElement;
};
const actionTypes = {
ADD_TOAST: "ADD_TOAST",
UPDATE_TOAST: "UPDATE_TOAST",
DISMISS_TOAST: "DISMISS_TOAST",
REMOVE_TOAST: "REMOVE_TOAST",
} as const;
let count = 0;
function genId() {
count = (count + 1) % Number.MAX_SAFE_INTEGER;
return count.toString();
}
type ActionType = typeof actionTypes;
type Action =
| {
type: ActionType["ADD_TOAST"];
toast: ToasterToast;
}
| {
type: ActionType["UPDATE_TOAST"];
toast: Partial<ToasterToast>;
}
| {
type: ActionType["DISMISS_TOAST"];
toastId?: ToasterToast["id"];
}
| {
type: ActionType["REMOVE_TOAST"];
toastId?: ToasterToast["id"];
};
interface State {
toasts: ToasterToast[];
}
const toastTimeouts = new Map<string, ReturnType<typeof setTimeout>>();
const addToRemoveQueue = (toastId: string) => {
if (toastTimeouts.has(toastId)) {
return;
}
const timeout = setTimeout(() => {
toastTimeouts.delete(toastId);
dispatch({
type: "REMOVE_TOAST",
toastId: toastId,
});
}, TOAST_REMOVE_DELAY);
toastTimeouts.set(toastId, timeout);
};
export const reducer = (state: State, action: Action): State => {
switch (action.type) {
case "ADD_TOAST":
return {
...state,
toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT),
};
case "UPDATE_TOAST":
return {
...state,
toasts: state.toasts.map((t) => (t.id === action.toast.id ? { ...t, ...action.toast } : t)),
};
case "DISMISS_TOAST": {
const { toastId } = action;
// ! Side effects ! - This could be extracted into a dismissToast() action,
// but I'll keep it here for simplicity
if (toastId) {
addToRemoveQueue(toastId);
} else {
state.toasts.forEach((toast) => {
addToRemoveQueue(toast.id);
});
}
return {
...state,
toasts: state.toasts.map((t) =>
t.id === toastId || toastId === undefined
? {
...t,
open: false,
}
: t,
),
};
}
case "REMOVE_TOAST":
if (action.toastId === undefined) {
return {
...state,
toasts: [],
};
}
return {
...state,
toasts: state.toasts.filter((t) => t.id !== action.toastId),
};
}
};
const listeners: Array<(state: State) => void> = [];
let memoryState: State = { toasts: [] };
function dispatch(action: Action) {
memoryState = reducer(memoryState, action);
listeners.forEach((listener) => {
listener(memoryState);
});
}
type Toast = Omit<ToasterToast, "id">;
function toast({ ...props }: Toast) {
const id = genId();
const update = (props: ToasterToast) =>
dispatch({
type: "UPDATE_TOAST",
toast: { ...props, id },
});
const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id });
dispatch({
type: "ADD_TOAST",
toast: {
...props,
id,
open: true,
onOpenChange: (open) => {
if (!open) dismiss();
},
},
});
return {
id: id,
dismiss,
update,
};
}
function useToast() {
const [state, setState] = React.useState<State>(memoryState);
React.useEffect(() => {
listeners.push(setState);
return () => {
const index = listeners.indexOf(setState);
if (index > -1) {
listeners.splice(index, 1);
}
};
}, [state]);
return {
...state,
toast,
dismiss: (toastId?: string) => dispatch({ type: "DISMISS_TOAST", toastId }),
};
}
export { useToast, toast };
-110
View File
@@ -1,110 +0,0 @@
import { useQuery } from "@tanstack/react-query";
import { supabase } from "@/integrations/supabase/client";
export interface Order {
id: string;
order_number: string;
customer_email: string;
customer_name: string | null;
items: any[];
total_amount: number;
currency: string;
status: string;
created_at: string;
}
export interface Customer {
id: string;
email: string;
name: string | null;
phone: string | null;
company: string | null;
total_orders: number;
total_spent: number;
created_at: string;
}
export interface Payment {
id: string;
order_id: string | null;
amount: number;
currency: string;
status: string;
payment_method: string | null;
created_at: string;
}
export function useOrders() {
return useQuery({
queryKey: ["admin-orders"],
queryFn: async () => {
const { data, error } = await supabase
.from("orders")
.select("*")
.order("created_at", { ascending: false });
if (error) throw error;
return data as Order[];
},
});
}
export function useCustomers() {
return useQuery({
queryKey: ["admin-customers"],
queryFn: async () => {
const { data, error } = await supabase
.from("customers")
.select("*")
.order("created_at", { ascending: false });
if (error) throw error;
return data as Customer[];
},
});
}
export function usePayments() {
return useQuery({
queryKey: ["admin-payments"],
queryFn: async () => {
const { data, error } = await supabase
.from("payments")
.select("*")
.order("created_at", { ascending: false });
if (error) throw error;
return data as Payment[];
},
});
}
export function useAdminStats() {
return useQuery({
queryKey: ["admin-stats"],
queryFn: async () => {
const [ordersRes, customersRes, paymentsRes] = await Promise.all([
supabase.from("orders").select("total_amount, status"),
supabase.from("customers").select("id"),
supabase.from("payments").select("amount, status"),
]);
const orders = ordersRes.data || [];
const customers = customersRes.data || [];
const payments = paymentsRes.data || [];
const totalRevenue = orders.reduce((sum, o) => sum + Number(o.total_amount), 0);
const pendingOrders = orders.filter(o => o.status === "pending").length;
const completedPayments = payments.filter(p => p.status === "completed").length;
return {
totalOrders: orders.length,
totalCustomers: customers.length,
totalRevenue,
pendingOrders,
totalPayments: payments.length,
completedPayments,
};
},
});
}
-78
View File
@@ -1,78 +0,0 @@
import { useState, useEffect } from "react";
import { User, Session } from "@supabase/supabase-js";
import { supabase } from "@/integrations/supabase/client";
export function useAuth() {
const [user, setUser] = useState<User | null>(null);
const [session, setSession] = useState<Session | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
// Set up auth state listener BEFORE getting session
const { data: { subscription } } = supabase.auth.onAuthStateChange(
(_event, session) => {
setSession(session);
setUser(session?.user ?? null);
setLoading(false);
}
);
// Get initial session
supabase.auth.getSession().then(({ data: { session } }) => {
setSession(session);
setUser(session?.user ?? null);
setLoading(false);
});
return () => subscription.unsubscribe();
}, []);
const signUp = async (email: string, password: string) => {
const { error } = await supabase.auth.signUp({
email,
password,
options: {
emailRedirectTo: window.location.origin,
},
});
return { error };
};
const signIn = async (email: string, password: string) => {
const { error } = await supabase.auth.signInWithPassword({
email,
password,
});
return { error };
};
const signOut = async () => {
const { error } = await supabase.auth.signOut();
return { error };
};
const resetPassword = async (email: string) => {
const { error } = await supabase.auth.resetPasswordForEmail(email, {
redirectTo: `${window.location.origin}/reset-password`,
});
return { error };
};
const updatePassword = async (newPassword: string) => {
const { error } = await supabase.auth.updateUser({
password: newPassword,
});
return { error };
};
return {
user,
session,
loading,
signUp,
signIn,
signOut,
resetPassword,
updatePassword,
};
}
-221
View File
@@ -1,221 +0,0 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
/* Lennart Svenssons Konditorivaror - Traditionell Svensk Konditoristil */
@import url('https://fonts.googleapis.com/css2?family=Cormorant+Garamond:ital,wght@0,400;0,500;0,600;0,700;1,400;1,500&family=Crimson+Text:ital,wght@0,400;0,600;0,700;1,400&display=swap');
@layer base {
:root {
--background: 38 25% 96%;
--foreground: 25 30% 12%;
--card: 40 30% 94%;
--card-foreground: 25 30% 12%;
--popover: 40 30% 94%;
--popover-foreground: 25 30% 12%;
--primary: 4 70% 45%;
--primary-foreground: 40 30% 96%;
--secondary: 35 20% 88%;
--secondary-foreground: 25 30% 18%;
--muted: 35 15% 85%;
--muted-foreground: 0 0% 0%;
--accent: 4 70% 45%;
--accent-foreground: 40 30% 96%;
--destructive: 4 70% 40%;
--destructive-foreground: 40 30% 96%;
--border: 30 20% 80%;
--input: 30 20% 80%;
--ring: 4 70% 45%;
--radius: 0.25rem;
/* Custom tokens */
--gradient-warm: linear-gradient(180deg, hsl(40 30% 94%) 0%, hsl(35 25% 90%) 100%);
--shadow-classic: 0 4px 20px -4px hsl(25 30% 12% / 0.15);
--shadow-card: 0 2px 8px hsl(25 30% 12% / 0.08);
--font-display: 'Cormorant Garamond', serif;
--font-body: 'Crimson Text', serif;
}
.dark {
--background: 25 20% 8%;
--foreground: 38 25% 92%;
--card: 25 18% 12%;
--card-foreground: 38 25% 92%;
--primary: 30 50% 55%;
--primary-foreground: 25 20% 8%;
--secondary: 25 15% 18%;
--secondary-foreground: 38 25% 92%;
--muted: 25 12% 22%;
--muted-foreground: 30 15% 60%;
--border: 25 15% 25%;
}
}
@layer base {
* {
@apply border-border;
}
html {
scroll-behavior: smooth;
scrollbar-width: none; /* Firefox */
-ms-overflow-style: none; /* IE/Edge */
}
html::-webkit-scrollbar {
display: none; /* Chrome, Safari, Opera */
}
body {
@apply bg-background text-foreground antialiased;
font-family: var(--font-body);
}
/* Ensure full-width layout (avoid template max-width/padding on #root) */
#root {
max-width: none;
margin: 0;
padding: 0;
text-align: inherit;
}
h1, h2, h3, h4, h5, h6 {
font-family: var(--font-display);
}
}
@layer components {
.hero-gradient {
background: var(--gradient-warm);
}
.text-accent-warm {
color: hsl(var(--accent));
}
.card-classic {
box-shadow: var(--shadow-card);
border: 1px solid hsl(var(--border));
transition: all 0.3s ease;
}
.card-classic:hover {
box-shadow: var(--shadow-classic);
transform: translateY(-4px);
}
.btn-classic {
@apply bg-primary text-primary-foreground font-semibold transition-all duration-300;
border: 2px solid hsl(var(--primary));
}
.btn-classic:hover {
@apply bg-transparent;
color: hsl(var(--primary));
}
.btn-outline-classic {
@apply bg-transparent font-semibold transition-all duration-300;
color: hsl(var(--primary));
border: 2px solid hsl(var(--primary));
}
.btn-outline-classic:hover {
@apply bg-primary text-primary-foreground;
}
.divider-ornament {
display: flex;
align-items: center;
gap: 1rem;
}
.divider-ornament::before,
.divider-ornament::after {
content: '';
flex: 1;
height: 1px;
background: linear-gradient(90deg, transparent, hsl(var(--border)), transparent);
}
.ornament {
color: hsl(var(--primary));
font-family: var(--font-display);
font-size: 1.5rem;
}
}
@layer utilities {
.animate-fade-up {
animation: fadeUp 0.8s ease-out forwards;
}
@keyframes fadeUp {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.text-balance {
text-wrap: balance;
}
/* Calculator slider — minimal, exclusive */
.calculator-slider {
-webkit-appearance: none;
appearance: none;
height: 4px;
background: hsl(var(--border));
border-radius: 999px;
outline: none;
cursor: pointer;
transition: background 0.2s ease;
}
.calculator-slider:hover {
background: hsl(var(--muted));
}
.calculator-slider::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 26px;
height: 26px;
border-radius: 50%;
background: hsl(var(--primary));
border: 3px solid hsl(var(--card));
box-shadow: 0 2px 10px hsl(var(--primary) / 0.35);
cursor: grab;
transition: transform 0.15s ease, box-shadow 0.15s ease;
}
.calculator-slider::-webkit-slider-thumb:hover {
transform: scale(1.1);
box-shadow: 0 4px 16px hsl(var(--primary) / 0.5);
}
.calculator-slider::-webkit-slider-thumb:active {
cursor: grabbing;
}
.calculator-slider::-moz-range-thumb {
width: 26px;
height: 26px;
border-radius: 50%;
background: hsl(var(--primary));
border: 3px solid hsl(var(--card));
box-shadow: 0 2px 10px hsl(var(--primary) / 0.35);
cursor: grab;
}
}
-17
View File
@@ -1,17 +0,0 @@
// This file is automatically generated. Do not edit it directly.
import { createClient } from '@supabase/supabase-js';
import type { Database } from './types';
const SUPABASE_URL = import.meta.env.VITE_SUPABASE_URL;
const SUPABASE_PUBLISHABLE_KEY = import.meta.env.VITE_SUPABASE_PUBLISHABLE_KEY;
// Import the supabase client like this:
// import { supabase } from "@/integrations/supabase/client";
export const supabase = createClient<Database>(SUPABASE_URL, SUPABASE_PUBLISHABLE_KEY, {
auth: {
storage: localStorage,
persistSession: true,
autoRefreshToken: true,
}
});
-419
View File
@@ -1,419 +0,0 @@
export type Json =
| string
| number
| boolean
| null
| { [key: string]: Json | undefined }
| Json[]
export type Database = {
// Allows to automatically instantiate createClient with right options
// instead of createClient<Database, { PostgrestVersion: 'XX' }>(URL, KEY)
__InternalSupabase: {
PostgrestVersion: "14.1"
}
public: {
Tables: {
customers: {
Row: {
company: string | null
created_at: string
email: string
id: string
name: string | null
phone: string | null
total_orders: number | null
total_spent: number | null
updated_at: string
}
Insert: {
company?: string | null
created_at?: string
email: string
id?: string
name?: string | null
phone?: string | null
total_orders?: number | null
total_spent?: number | null
updated_at?: string
}
Update: {
company?: string | null
created_at?: string
email?: string
id?: string
name?: string | null
phone?: string | null
total_orders?: number | null
total_spent?: number | null
updated_at?: string
}
Relationships: []
}
orders: {
Row: {
created_at: string
currency: string
customer_email: string
customer_id: string | null
customer_name: string | null
id: string
items: Json
order_number: string
shopify_checkout_id: string | null
status: string
total_amount: number
updated_at: string
}
Insert: {
created_at?: string
currency?: string
customer_email: string
customer_id?: string | null
customer_name?: string | null
id?: string
items?: Json
order_number: string
shopify_checkout_id?: string | null
status?: string
total_amount: number
updated_at?: string
}
Update: {
created_at?: string
currency?: string
customer_email?: string
customer_id?: string | null
customer_name?: string | null
id?: string
items?: Json
order_number?: string
shopify_checkout_id?: string | null
status?: string
total_amount?: number
updated_at?: string
}
Relationships: [
{
foreignKeyName: "orders_customer_id_fkey"
columns: ["customer_id"]
isOneToOne: false
referencedRelation: "customers"
referencedColumns: ["id"]
},
]
}
payments: {
Row: {
amount: number
created_at: string
currency: string
id: string
order_id: string | null
payment_method: string | null
shopify_payment_id: string | null
status: string
}
Insert: {
amount: number
created_at?: string
currency?: string
id?: string
order_id?: string | null
payment_method?: string | null
shopify_payment_id?: string | null
status?: string
}
Update: {
amount?: number
created_at?: string
currency?: string
id?: string
order_id?: string | null
payment_method?: string | null
shopify_payment_id?: string | null
status?: string
}
Relationships: [
{
foreignKeyName: "payments_order_id_fkey"
columns: ["order_id"]
isOneToOne: false
referencedRelation: "orders"
referencedColumns: ["id"]
},
]
}
profiles: {
Row: {
address: string | null
city: string | null
company_name: string | null
created_at: string
display_name: string | null
id: string
phone: string | null
postal_code: string | null
street_address: string | null
updated_at: string
user_id: string
}
Insert: {
address?: string | null
city?: string | null
company_name?: string | null
created_at?: string
display_name?: string | null
id?: string
phone?: string | null
postal_code?: string | null
street_address?: string | null
updated_at?: string
user_id: string
}
Update: {
address?: string | null
city?: string | null
company_name?: string | null
created_at?: string
display_name?: string | null
id?: string
phone?: string | null
postal_code?: string | null
street_address?: string | null
updated_at?: string
user_id?: string
}
Relationships: []
}
subscriptions: {
Row: {
cancel_at_period_end: boolean
collection_method: string | null
company_name: string | null
created_at: string
current_period_end: string | null
current_period_start: string | null
customer_email: string
environment: string
id: string
kg_per_week: number | null
monthly_amount: number | null
price_id: string | null
status: string
stripe_customer_id: string
stripe_subscription_id: string
updated_at: string
user_id: string | null
}
Insert: {
cancel_at_period_end?: boolean
collection_method?: string | null
company_name?: string | null
created_at?: string
current_period_end?: string | null
current_period_start?: string | null
customer_email: string
environment?: string
id?: string
kg_per_week?: number | null
monthly_amount?: number | null
price_id?: string | null
status?: string
stripe_customer_id: string
stripe_subscription_id: string
updated_at?: string
user_id?: string | null
}
Update: {
cancel_at_period_end?: boolean
collection_method?: string | null
company_name?: string | null
created_at?: string
current_period_end?: string | null
current_period_start?: string | null
customer_email?: string
environment?: string
id?: string
kg_per_week?: number | null
monthly_amount?: number | null
price_id?: string | null
status?: string
stripe_customer_id?: string
stripe_subscription_id?: string
updated_at?: string
user_id?: string | null
}
Relationships: []
}
user_roles: {
Row: {
created_at: string
id: string
role: Database["public"]["Enums"]["app_role"]
user_id: string
}
Insert: {
created_at?: string
id?: string
role: Database["public"]["Enums"]["app_role"]
user_id: string
}
Update: {
created_at?: string
id?: string
role?: Database["public"]["Enums"]["app_role"]
user_id?: string
}
Relationships: []
}
}
Views: {
[_ in never]: never
}
Functions: {
has_active_kaka_subscription: {
Args: { user_uuid: string }
Returns: boolean
}
has_role: {
Args: {
_role: Database["public"]["Enums"]["app_role"]
_user_id: string
}
Returns: boolean
}
}
Enums: {
app_role: "admin" | "user"
}
CompositeTypes: {
[_ in never]: never
}
}
}
type DatabaseWithoutInternals = Omit<Database, "__InternalSupabase">
type DefaultSchema = DatabaseWithoutInternals[Extract<keyof Database, "public">]
export type Tables<
DefaultSchemaTableNameOrOptions extends
| keyof (DefaultSchema["Tables"] & DefaultSchema["Views"])
| { schema: keyof DatabaseWithoutInternals },
TableName extends DefaultSchemaTableNameOrOptions extends {
schema: keyof DatabaseWithoutInternals
}
? keyof (DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"] &
DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Views"])
: never = never,
> = DefaultSchemaTableNameOrOptions extends {
schema: keyof DatabaseWithoutInternals
}
? (DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"] &
DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Views"])[TableName] extends {
Row: infer R
}
? R
: never
: DefaultSchemaTableNameOrOptions extends keyof (DefaultSchema["Tables"] &
DefaultSchema["Views"])
? (DefaultSchema["Tables"] &
DefaultSchema["Views"])[DefaultSchemaTableNameOrOptions] extends {
Row: infer R
}
? R
: never
: never
export type TablesInsert<
DefaultSchemaTableNameOrOptions extends
| keyof DefaultSchema["Tables"]
| { schema: keyof DatabaseWithoutInternals },
TableName extends DefaultSchemaTableNameOrOptions extends {
schema: keyof DatabaseWithoutInternals
}
? keyof DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"]
: never = never,
> = DefaultSchemaTableNameOrOptions extends {
schema: keyof DatabaseWithoutInternals
}
? DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"][TableName] extends {
Insert: infer I
}
? I
: never
: DefaultSchemaTableNameOrOptions extends keyof DefaultSchema["Tables"]
? DefaultSchema["Tables"][DefaultSchemaTableNameOrOptions] extends {
Insert: infer I
}
? I
: never
: never
export type TablesUpdate<
DefaultSchemaTableNameOrOptions extends
| keyof DefaultSchema["Tables"]
| { schema: keyof DatabaseWithoutInternals },
TableName extends DefaultSchemaTableNameOrOptions extends {
schema: keyof DatabaseWithoutInternals
}
? keyof DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"]
: never = never,
> = DefaultSchemaTableNameOrOptions extends {
schema: keyof DatabaseWithoutInternals
}
? DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"][TableName] extends {
Update: infer U
}
? U
: never
: DefaultSchemaTableNameOrOptions extends keyof DefaultSchema["Tables"]
? DefaultSchema["Tables"][DefaultSchemaTableNameOrOptions] extends {
Update: infer U
}
? U
: never
: never
export type Enums<
DefaultSchemaEnumNameOrOptions extends
| keyof DefaultSchema["Enums"]
| { schema: keyof DatabaseWithoutInternals },
EnumName extends DefaultSchemaEnumNameOrOptions extends {
schema: keyof DatabaseWithoutInternals
}
? keyof DatabaseWithoutInternals[DefaultSchemaEnumNameOrOptions["schema"]]["Enums"]
: never = never,
> = DefaultSchemaEnumNameOrOptions extends {
schema: keyof DatabaseWithoutInternals
}
? DatabaseWithoutInternals[DefaultSchemaEnumNameOrOptions["schema"]]["Enums"][EnumName]
: DefaultSchemaEnumNameOrOptions extends keyof DefaultSchema["Enums"]
? DefaultSchema["Enums"][DefaultSchemaEnumNameOrOptions]
: never
export type CompositeTypes<
PublicCompositeTypeNameOrOptions extends
| keyof DefaultSchema["CompositeTypes"]
| { schema: keyof DatabaseWithoutInternals },
CompositeTypeName extends PublicCompositeTypeNameOrOptions extends {
schema: keyof DatabaseWithoutInternals
}
? keyof DatabaseWithoutInternals[PublicCompositeTypeNameOrOptions["schema"]]["CompositeTypes"]
: never = never,
> = PublicCompositeTypeNameOrOptions extends {
schema: keyof DatabaseWithoutInternals
}
? DatabaseWithoutInternals[PublicCompositeTypeNameOrOptions["schema"]]["CompositeTypes"][CompositeTypeName]
: PublicCompositeTypeNameOrOptions extends keyof DefaultSchema["CompositeTypes"]
? DefaultSchema["CompositeTypes"][PublicCompositeTypeNameOrOptions]
: never
export const Constants = {
public: {
Enums: {
app_role: ["admin", "user"],
},
},
} as const
-255
View File
@@ -1,255 +0,0 @@
import { toast } from "sonner";
// Shopify API Configuration
const SHOPIFY_API_VERSION = '2025-07';
const SHOPIFY_STORE_PERMANENT_DOMAIN = 'sweet-office-treats-2ar62.myshopify.com';
const SHOPIFY_STOREFRONT_URL = `https://${SHOPIFY_STORE_PERMANENT_DOMAIN}/api/${SHOPIFY_API_VERSION}/graphql.json`;
const SHOPIFY_STOREFRONT_TOKEN = 'd9c2c43fc30d96d1742cc76da58c4ddb';
export interface ShopifyProduct {
node: {
id: string;
title: string;
description: string;
handle: string;
priceRange: {
minVariantPrice: {
amount: string;
currencyCode: string;
};
};
images: {
edges: Array<{
node: {
url: string;
altText: string | null;
};
}>;
};
variants: {
edges: Array<{
node: {
id: string;
title: string;
price: {
amount: string;
currencyCode: string;
};
availableForSale: boolean;
selectedOptions: Array<{
name: string;
value: string;
}>;
};
}>;
};
sellingPlanGroups?: {
edges: Array<{
node: {
name: string;
sellingPlans: {
edges: Array<{
node: {
id: string;
name: string;
};
}>;
};
};
}>;
};
options: Array<{
name: string;
values: string[];
}>;
};
}
// Storefront API helper function
export async function storefrontApiRequest(query: string, variables: Record<string, unknown> = {}) {
const response = await fetch(SHOPIFY_STOREFRONT_URL, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Shopify-Storefront-Access-Token': SHOPIFY_STOREFRONT_TOKEN
},
body: JSON.stringify({
query,
variables,
}),
});
if (response.status === 402) {
toast.error("Shopify: Betalning krävs", {
description: "Shopify API kräver en aktiv betalplan. Besök admin.shopify.com för att uppgradera.",
});
return null;
}
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data = await response.json();
if (data.errors) {
throw new Error(`Error calling Shopify: ${data.errors.map((e: { message: string }) => e.message).join(', ')}`);
}
return data;
}
// Fetch products query
export const STOREFRONT_PRODUCTS_QUERY = `
query GetProducts($first: Int!, $query: String) {
products(first: $first, query: $query) {
edges {
node {
id
title
description
handle
priceRange {
minVariantPrice {
amount
currencyCode
}
}
images(first: 5) {
edges {
node {
url
altText
}
}
}
variants(first: 10) {
edges {
node {
id
title
price {
amount
currencyCode
}
availableForSale
selectedOptions {
name
value
}
}
}
}
sellingPlanGroups(first: 5) {
edges {
node {
name
sellingPlans(first: 10) {
edges {
node {
id
name
}
}
}
}
}
}
options {
name
values
}
}
}
}
}
`;
// Cart mutations
export const CART_QUERY = `
query cart($id: ID!) {
cart(id: $id) {
id
totalQuantity
checkoutUrl
lines(first: 100) {
edges {
node {
id
quantity
merchandise {
... on ProductVariant {
id
title
price {
amount
currencyCode
}
}
}
}
}
}
}
}
`;
export const CART_CREATE_MUTATION = `
mutation cartCreate($input: CartInput!) {
cartCreate(input: $input) {
cart {
id
checkoutUrl
lines(first: 100) { edges { node { id merchandise { ... on ProductVariant { id } } } } }
}
userErrors { field message }
}
}
`;
export const CART_LINES_ADD_MUTATION = `
mutation cartLinesAdd($cartId: ID!, $lines: [CartLineInput!]!) {
cartLinesAdd(cartId: $cartId, lines: $lines) {
cart {
id
lines(first: 100) { edges { node { id merchandise { ... on ProductVariant { id } } } } }
}
userErrors { field message }
}
}
`;
export const CART_LINES_UPDATE_MUTATION = `
mutation cartLinesUpdate($cartId: ID!, $lines: [CartLineUpdateInput!]!) {
cartLinesUpdate(cartId: $cartId, lines: $lines) {
cart { id }
userErrors { field message }
}
}
`;
export const CART_LINES_REMOVE_MUTATION = `
mutation cartLinesRemove($cartId: ID!, $lineIds: [ID!]!) {
cartLinesRemove(cartId: $cartId, lineIds: $lineIds) {
cart { id }
userErrors { field message }
}
}
`;
export function formatCheckoutUrl(checkoutUrl: string): string {
try {
const url = new URL(checkoutUrl);
url.searchParams.set('channel', 'online_store');
return url.toString();
} catch {
return checkoutUrl;
}
}
export function isCartNotFoundError(userErrors: Array<{ field: string[] | null; message: string }>): boolean {
return userErrors.some(e =>
e.message.toLowerCase().includes('cart not found') ||
e.message.toLowerCase().includes('does not exist')
);
}
-31
View File
@@ -1,31 +0,0 @@
import { loadStripe, Stripe } from "@stripe/stripe-js";
export type StripeEnv = "sandbox" | "live";
const clientToken = import.meta.env.VITE_PAYMENTS_CLIENT_TOKEN as string | undefined;
function paymentsEnvironment(): StripeEnv {
if (clientToken?.startsWith("pk_test_")) return "sandbox";
if (clientToken?.startsWith("pk_live_")) return "live";
throw new Error(
"Betalningar är inte konfigurerade för denna build. Slutför go-live i Payments-fliken för att aktivera skarpa betalningar."
);
}
let stripePromise: Promise<Stripe | null> | null = null;
export function getStripe(): Promise<Stripe | null> {
if (!stripePromise) {
paymentsEnvironment();
stripePromise = loadStripe(clientToken as string);
}
return stripePromise;
}
export function getStripeEnvironment(): StripeEnv {
return paymentsEnvironment();
}
export function hasPaymentsConfigured(): boolean {
return !!clientToken && (clientToken.startsWith("pk_test_") || clientToken.startsWith("pk_live_"));
}
-6
View File
@@ -1,6 +0,0 @@
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
-5
View File
@@ -1,5 +0,0 @@
import { createRoot } from "react-dom/client";
import App from "./App.tsx";
import "./index.css";
createRoot(document.getElementById("root")!).render(<App />);
-476
View File
@@ -1,476 +0,0 @@
import { useState, useEffect } from "react";
import { useNavigate } from "react-router-dom";
import { z } from "zod";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { useAuth } from "@/hooks/useAuth";
import { useToast } from "@/hooks/use-toast";
import { supabase } from "@/integrations/supabase/client";
import { getStripeEnvironment, hasPaymentsConfigured } from "@/lib/stripe";
import Header from "@/components/Header";
import { Save, Package, ExternalLink } from "lucide-react";
// Validate Stockholm postal codes (100 00 - 199 99)
const isStockholmPostalCode = (postalCode: string) => {
if (!postalCode || postalCode.trim() === "") return true; // Allow empty
const cleaned = postalCode.replace(/\s/g, "");
if (!/^\d{5}$/.test(cleaned)) return false;
const num = parseInt(cleaned, 10);
return num >= 10000 && num <= 19999;
};
const profileSchema = z.object({
company_name: z.string().trim().max(100, { message: "Företagsnamn får max vara 100 tecken" }).optional(),
street_address: z.string().trim().max(200, { message: "Gatuadress får max vara 200 tecken" }).optional(),
postal_code: z
.string()
.trim()
.max(10, { message: "Postnummer får max vara 10 tecken" })
.refine((val) => isStockholmPostalCode(val), {
message: "Vi levererar endast inom Storstockholm (100 00 199 99)",
})
.optional(),
city: z.string().trim().max(100, { message: "Ort får max vara 100 tecken" }).optional(),
});
interface ProfileData {
company_name: string;
street_address: string;
postal_code: string;
city: string;
}
const getPostalCodeError = (postalCode: string) => {
if (!postalCode || postalCode.trim() === "") return null;
const cleaned = postalCode.replace(/\s/g, "");
if (cleaned.length < 5) return null; // Don't show error while typing
if (!/^\d{5}$/.test(cleaned)) return "Ange ett giltigt postnummer (5 siffror)";
const num = parseInt(cleaned, 10);
if (num < 10000 || num > 19999) {
return "Vi levererar endast inom Storstockholm (100 00 199 99)";
}
return null;
};
interface SubscriptionRow {
id: string;
status: string;
kg_per_week: number | null;
monthly_amount: number | null;
current_period_end: string | null;
cancel_at_period_end: boolean;
collection_method: string | null;
}
const Account = () => {
const [profile, setProfile] = useState<ProfileData>({
company_name: "",
street_address: "",
postal_code: "",
city: "",
});
const [originalProfile, setOriginalProfile] = useState<ProfileData>({
company_name: "",
street_address: "",
postal_code: "",
city: "",
});
const [isLoading, setIsLoading] = useState(true);
const [isSaving, setIsSaving] = useState(false);
const [subscription, setSubscription] = useState<SubscriptionRow | null>(null);
const [openingPortal, setOpeningPortal] = useState(false);
const { user, loading: authLoading, signOut } = useAuth();
const { toast } = useToast();
const navigate = useNavigate();
const hasChanges =
profile.company_name !== originalProfile.company_name ||
profile.street_address !== originalProfile.street_address ||
profile.postal_code !== originalProfile.postal_code ||
profile.city !== originalProfile.city;
useEffect(() => {
if (!authLoading && !user) {
navigate("/");
return;
}
if (user) {
fetchProfile();
fetchSubscription();
}
}, [user, authLoading, navigate]);
const fetchSubscription = async () => {
if (!user) return;
try {
const env = hasPaymentsConfigured() ? getStripeEnvironment() : "sandbox";
const { data } = await supabase
.from("subscriptions")
.select("id, status, kg_per_week, monthly_amount, current_period_end, cancel_at_period_end, collection_method")
.eq("user_id", user.id)
.eq("environment", env)
.order("created_at", { ascending: false })
.limit(1)
.maybeSingle();
setSubscription((data as SubscriptionRow | null) ?? null);
} catch (e) {
console.error("fetchSubscription error:", e);
}
};
const handleManageSubscription = async () => {
if (!hasPaymentsConfigured()) {
toast({ title: "Betalningar inte konfigurerade", variant: "destructive" });
return;
}
setOpeningPortal(true);
try {
const { data, error } = await supabase.functions.invoke("create-portal-session", {
body: {
return_url: `${window.location.origin}/account`,
environment: getStripeEnvironment(),
},
});
if (error) throw error;
if ((data as any)?.error) throw new Error((data as any).error);
const url = (data as any)?.url;
if (!url) throw new Error("Ingen portal-URL mottagen");
window.open(url, "_blank", "noopener,noreferrer");
} catch (e: any) {
toast({
title: "Kunde inte öppna kundportalen",
description: e?.message ?? String(e),
variant: "destructive",
});
} finally {
setOpeningPortal(false);
}
};
const fetchProfile = async () => {
try {
const { data, error } = await supabase
.from("profiles")
.select("company_name, street_address, postal_code, city")
.eq("user_id", user!.id)
.maybeSingle();
if (error) {
console.error("Error fetching profile:", error);
toast({
title: "Kunde inte hämta profil",
description: error.message,
variant: "destructive",
});
} else if (data) {
const profileData = {
company_name: data.company_name || "",
street_address: data.street_address || "",
postal_code: data.postal_code || "",
city: data.city || "",
};
setProfile(profileData);
setOriginalProfile(profileData);
}
} finally {
setIsLoading(false);
}
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
const validation = profileSchema.safeParse(profile);
if (!validation.success) {
toast({
title: "Fel",
description: validation.error.errors[0].message,
variant: "destructive",
});
return;
}
setIsSaving(true);
try {
const { error } = await supabase
.from("profiles")
.update({
company_name: profile.company_name || null,
street_address: profile.street_address || null,
postal_code: profile.postal_code || null,
city: profile.city || null,
})
.eq("user_id", user!.id);
if (error) {
toast({
title: "Kunde inte spara profil",
description: error.message,
variant: "destructive",
});
} else {
// Navigate to confirmation page after successful save
navigate("/account/confirmation", { state: { justSaved: true } });
}
} finally {
setIsSaving(false);
}
};
const handleSignOut = async () => {
await signOut();
navigate("/");
};
if (authLoading || isLoading) {
return (
<div className="min-h-screen bg-background">
<Header />
<div className="pt-24 flex items-center justify-center">
<p className="text-muted-foreground">Laddar...</p>
</div>
</div>
);
}
return (
<div className="min-h-screen bg-background">
<Header />
<div className="pt-24 px-4 pb-12">
{/* Back link - top left */}
<div className="mb-6">
<button
type="button"
onClick={() => navigate("/")}
className="text-sm text-muted-foreground hover:underline"
>
Tillbaka till startsidan
</button>
</div>
<div className="max-w-2xl mx-auto">
{/* Header */}
<div className="text-center mb-8">
<h1 className="font-serif text-3xl md:text-4xl font-semibold mb-2">
Mitt konto
</h1>
<p className="text-muted-foreground">{user?.email}</p>
</div>
{/* Profile Card */}
<div className="bg-card border border-border rounded-lg p-6 md:p-8 shadow-sm mb-6">
<h2 className="font-serif text-xl mb-6">Mina uppgifter</h2>
<form onSubmit={handleSubmit} className="space-y-5">
<div className="space-y-2">
<Label htmlFor="email">E-post</Label>
<Input
id="email"
type="email"
value={user?.email || ""}
disabled
className="bg-muted"
/>
</div>
<div className="space-y-2">
<Label htmlFor="company_name">Företagsnamn</Label>
<Input
id="company_name"
type="text"
value={profile.company_name}
onChange={(e) =>
setProfile({ ...profile, company_name: e.target.value })
}
placeholder="Företagsnamn"
/>
</div>
<div className="space-y-4">
<Label>Leveransadress</Label>
<div className="space-y-2">
<Input
id="street_address"
type="text"
value={profile.street_address}
onChange={(e) =>
setProfile({ ...profile, street_address: e.target.value })
}
placeholder="Gatuadress"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1">
<Input
id="postal_code"
type="text"
value={profile.postal_code}
onChange={(e) =>
setProfile({ ...profile, postal_code: e.target.value })
}
placeholder="Postnummer"
className={getPostalCodeError(profile.postal_code) ? "border-destructive focus-visible:ring-destructive" : ""}
/>
{getPostalCodeError(profile.postal_code) && (
<p className="text-destructive text-xs">
{getPostalCodeError(profile.postal_code)}
</p>
)}
</div>
<div className="space-y-2">
<Input
id="city"
type="text"
value={profile.city}
onChange={(e) =>
setProfile({ ...profile, city: e.target.value })
}
placeholder="Ort"
/>
</div>
</div>
<p className="text-xs text-muted-foreground">
Denna adress används vid beställning av abonnemang.
</p>
</div>
{hasChanges && (
<Button
type="submit"
className="w-full bg-primary text-primary-foreground hover:bg-primary/90"
disabled={isSaving}
>
<Save className="h-4 w-4 mr-2" />
{isSaving ? "Sparar..." : "Spara ändringar"}
</Button>
)}
</form>
</div>
{/* Subscription Section */}
<div className="bg-card border border-border rounded-lg p-6 md:p-8 shadow-sm mb-6">
<div className="flex items-center gap-2 mb-6">
<Package className="h-5 w-5 text-primary" />
<h2 className="font-serif text-xl">Mitt abonnemang</h2>
</div>
{subscription ? (
<div className="space-y-4">
<div className="flex flex-wrap items-baseline justify-between gap-2">
<div>
<div className="text-muted-foreground text-xs uppercase tracking-widest">
Ditt abonnemang
</div>
<div className="font-serif text-2xl font-semibold text-foreground">
{subscription.kg_per_week ?? "—"} kg / vecka
</div>
{subscription.monthly_amount && (
<div className="text-muted-foreground text-sm">
{Math.round(subscription.monthly_amount * 1.25).toLocaleString("sv-SE")} kr/mån ink moms
{" · "}
{subscription.collection_method === "send_invoice" ? "Faktura" : "Kort"}
</div>
)}
</div>
<span
className={`text-xs px-2 py-1 rounded-sm border ${
subscription.cancel_at_period_end
? "border-destructive/40 text-destructive"
: subscription.status === "past_due"
? "border-amber-500/40 text-amber-700"
: "border-primary/40 text-primary"
}`}
>
{subscription.cancel_at_period_end
? "Avslutas vid periodens slut"
: subscription.status === "active"
? "Aktiv"
: subscription.status === "trialing"
? "Provperiod"
: subscription.status === "past_due"
? "Obetald Stripe försöker igen"
: subscription.status === "canceled"
? "Avslutad"
: subscription.status}
</span>
</div>
{subscription.current_period_end && (
<p className="text-xs text-muted-foreground">
{subscription.cancel_at_period_end || subscription.status === "canceled"
? "Sista leveransperiod till: "
: "Nästa förnyelse: "}
{new Date(subscription.current_period_end).toLocaleDateString("sv-SE")}
</p>
)}
<Button
onClick={() => navigate("/account/change-subscription")}
variant="outline"
className="w-full"
disabled={subscription.cancel_at_period_end || subscription.status === "canceled"}
>
<Package className="h-4 w-4 mr-2" />
Ändra kg/vecka
</Button>
<Button
onClick={handleManageSubscription}
disabled={openingPortal}
className="w-full"
>
<ExternalLink className="h-4 w-4 mr-2" />
{openingPortal ? "Öppnar kundportal..." : "Hantera abonnemang"}
</Button>
<p className="text-xs text-muted-foreground text-center">
Ändra kort, ladda ner fakturor eller säg upp tillgång kvar till periodens slut.
</p>
</div>
) : (
<div className="space-y-4">
<p className="text-muted-foreground text-sm">
Du har för närvarande inget aktivt abonnemang.
</p>
<Button
variant="outline"
onClick={() => {
navigate("/");
setTimeout(() => {
const element = document.getElementById("abonnemang");
if (element) {
element.scrollIntoView({ behavior: "smooth" });
}
}, 100);
}}
className="w-full"
>
Beställ abonnemang
</Button>
</div>
)}
</div>
<div className="text-center mt-6">
<button
type="button"
onClick={handleSignOut}
className="text-sm text-destructive hover:underline"
>
Logga ut
</button>
</div>
</div>
</div>
</div>
);
};
export default Account;
-167
View File
@@ -1,167 +0,0 @@
import { useState, useEffect } from "react";
import { useNavigate, useLocation } from "react-router-dom";
import { Button } from "@/components/ui/button";
import { CheckCircle, Package, User } from "lucide-react";
import Header from "@/components/Header";
import { useAuth } from "@/hooks/useAuth";
import { supabase } from "@/integrations/supabase/client";
interface ProfileData {
street_address: string;
postal_code: string;
city: string;
}
const AccountConfirmation = () => {
const navigate = useNavigate();
const location = useLocation();
const { user, loading: authLoading } = useAuth();
const [profile, setProfile] = useState<ProfileData | null>(null);
const [isLoading, setIsLoading] = useState(true);
// Check if user just saved their profile
const justSaved = location.state?.justSaved === true;
useEffect(() => {
if (!authLoading && !user) {
navigate("/");
return;
}
if (user) {
fetchProfile();
}
}, [user, authLoading, navigate]);
const fetchProfile = async () => {
try {
const { data } = await supabase
.from("profiles")
.select("street_address, postal_code, city")
.eq("user_id", user!.id)
.maybeSingle();
if (data) {
setProfile({
street_address: data.street_address || "",
postal_code: data.postal_code || "",
city: data.city || "",
});
}
} finally {
setIsLoading(false);
}
};
if (authLoading || isLoading) {
return (
<div className="min-h-screen bg-background">
<Header />
<div className="pt-24 flex items-center justify-center">
<p className="text-muted-foreground">Laddar...</p>
</div>
</div>
);
}
const hasAddress = profile && (profile.street_address || profile.postal_code || profile.city);
return (
<div className="min-h-screen bg-background">
<Header />
<div className="pt-24 px-4 pb-12">
{/* Back link */}
<div className="mb-6">
<button
type="button"
onClick={() => navigate("/")}
className="text-sm text-muted-foreground hover:underline"
>
Tillbaka till startsidan
</button>
</div>
<div className="max-w-2xl mx-auto">
{/* Header section */}
<div className="bg-card border border-border rounded-lg p-6 md:p-8 shadow-sm mb-6">
<div className="flex items-center gap-4 mb-4">
{justSaved ? (
<CheckCircle className="h-8 w-8 text-primary flex-shrink-0" />
) : (
<User className="h-8 w-8 text-primary flex-shrink-0" />
)}
<div>
<h1 className="font-serif text-2xl font-semibold">
{justSaved ? "Uppgifter sparade!" : "Mitt konto"}
</h1>
{justSaved && (
<p className="text-muted-foreground text-sm">
Dina kontuppgifter har uppdaterats.
</p>
)}
{!justSaved && user?.email && (
<p className="text-muted-foreground text-sm">
{user.email}
</p>
)}
</div>
</div>
{hasAddress && (
<div className="mt-4 pt-4 border-t border-border">
<p className="text-sm font-medium mb-2">Leveransadress:</p>
<p className="text-muted-foreground text-sm">
{profile.street_address && <span>{profile.street_address}<br /></span>}
{(profile.postal_code || profile.city) && (
<span>{profile.postal_code} {profile.city}</span>
)}
</p>
</div>
)}
<div className="mt-4 pt-4 border-t border-border">
<Button
variant="outline"
onClick={() => navigate("/account")}
className="w-full"
>
Redigera uppgifter
</Button>
</div>
</div>
{/* Subscription status */}
<div className="bg-card border border-border rounded-lg p-6 md:p-8 shadow-sm mb-6">
<div className="flex items-center gap-3 mb-4">
<Package className="h-6 w-6 text-primary" />
<h2 className="font-serif text-xl font-semibold">Mitt abonnemang</h2>
</div>
<div className="bg-muted/50 rounded-lg p-4 mb-6">
<p className="text-muted-foreground text-sm">
Du har inget aktivt abonnemang just nu.
</p>
</div>
<Button
onClick={() => {
navigate("/");
setTimeout(() => {
const element = document.getElementById("abonnemang");
if (element) {
element.scrollIntoView({ behavior: "smooth" });
}
}, 100);
}}
className="w-full bg-primary text-primary-foreground hover:bg-primary/90"
>
Beställ abonnemang
</Button>
</div>
</div>
</div>
</div>
);
};
export default AccountConfirmation;
-166
View File
@@ -1,166 +0,0 @@
import { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import { supabase } from "@/integrations/supabase/client";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Button } from "@/components/ui/button";
import { AdminStats } from "@/components/admin/AdminStats";
import { OrdersTable } from "@/components/admin/OrdersTable";
import { CustomersTable } from "@/components/admin/CustomersTable";
import { PaymentsTable } from "@/components/admin/PaymentsTable";
import { LeadsGenerator } from "@/components/admin/LeadsGenerator";
import AuthDialog from "@/components/AuthDialog";
import { Package, Users, CreditCard, LogOut, ArrowLeft, Sparkles, Lock } from "lucide-react";
import { Skeleton } from "@/components/ui/skeleton";
type AuthState = "loading" | "signed_out" | "not_admin" | "admin";
export default function Admin() {
const navigate = useNavigate();
const [state, setState] = useState<AuthState>("loading");
const [authOpen, setAuthOpen] = useState(false);
const [authMode, setAuthMode] = useState<"login" | "signup">("login");
useEffect(() => {
const check = async (session: any) => {
if (!session?.user) {
setState("signed_out");
return;
}
const { data, error } = await supabase
.from("user_roles")
.select("role")
.eq("user_id", session.user.id)
.eq("role", "admin")
.maybeSingle();
if (error) console.error("Role check failed:", error);
setState(data ? "admin" : "not_admin");
};
const { data: { subscription } } = supabase.auth.onAuthStateChange((_e, session) => {
check(session);
});
supabase.auth.getSession().then(({ data }) => check(data.session));
return () => subscription.unsubscribe();
}, []);
const handleLogout = async () => {
await supabase.auth.signOut();
};
if (state === "loading") {
return (
<div className="min-h-screen bg-background p-8">
<div className="max-w-7xl mx-auto space-y-8">
<Skeleton className="h-10 w-48" />
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
{[...Array(4)].map((_, i) => <Skeleton key={i} className="h-32" />)}
</div>
<Skeleton className="h-96" />
</div>
</div>
);
}
if (state === "signed_out") {
return (
<>
<div className="min-h-screen flex items-center justify-center bg-background px-6">
<div className="max-w-md text-center space-y-6">
<Lock className="h-12 w-12 mx-auto text-primary" />
<h1 className="text-3xl font-serif">Admin-inloggning krävs</h1>
<p className="text-muted-foreground">
Denna sida är privat. Logga in med ditt admin-konto för att se ordrar och annan intern information.
</p>
<div className="flex gap-3 justify-center">
<Button onClick={() => setAuthOpen(true)}>Logga in</Button>
<Button variant="outline" onClick={() => navigate("/")}>Till startsidan</Button>
</div>
</div>
</div>
<AuthDialog open={authOpen} onOpenChange={setAuthOpen} mode={authMode} onModeChange={setAuthMode} />
</>
);
}
if (state === "not_admin") {
return (
<div className="min-h-screen flex items-center justify-center bg-background px-6">
<div className="max-w-md text-center space-y-6">
<Lock className="h-12 w-12 mx-auto text-destructive" />
<h1 className="text-3xl font-serif">Ingen behörighet</h1>
<p className="text-muted-foreground">
Ditt konto har inte tillgång till admin-sidan. Endast ägaren kan se den här sidan.
</p>
<div className="flex gap-3 justify-center">
<Button variant="outline" onClick={handleLogout}>Logga ut</Button>
<Button onClick={() => navigate("/")}>Till startsidan</Button>
</div>
</div>
</div>
);
}
return (
<div className="min-h-screen bg-background">
<header className="border-b">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<Button variant="ghost" size="icon" onClick={() => navigate("/")}>
<ArrowLeft className="h-5 w-5" />
</Button>
<h1 className="text-2xl font-bold">Admin Dashboard</h1>
</div>
<Button variant="outline" onClick={handleLogout}>
<LogOut className="h-4 w-4 mr-2" />
Logga ut
</Button>
</div>
</div>
</header>
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<div className="space-y-8">
<AdminStats />
<Tabs defaultValue="orders" className="space-y-6">
<TabsList>
<TabsTrigger value="orders" className="gap-2">
<Package className="h-4 w-4" />
Ordrar
</TabsTrigger>
<TabsTrigger value="customers" className="gap-2">
<Users className="h-4 w-4" />
Kunder
</TabsTrigger>
<TabsTrigger value="payments" className="gap-2">
<CreditCard className="h-4 w-4" />
Betalningar
</TabsTrigger>
<TabsTrigger value="leads" className="gap-2">
<Sparkles className="h-4 w-4" />
AI Leads
</TabsTrigger>
</TabsList>
<TabsContent value="orders">
<OrdersTable />
</TabsContent>
<TabsContent value="customers">
<CustomersTable />
</TabsContent>
<TabsContent value="payments">
<PaymentsTable />
</TabsContent>
<TabsContent value="leads">
<LeadsGenerator />
</TabsContent>
</Tabs>
</div>
</main>
</div>
);
}
-216
View File
@@ -1,216 +0,0 @@
import { useEffect, useMemo, useState } from "react";
import { useNavigate } from "react-router-dom";
import { supabase } from "@/integrations/supabase/client";
import { useAuth } from "@/hooks/useAuth";
import { useToast } from "@/hooks/use-toast";
import { getStripeEnvironment, hasPaymentsConfigured } from "@/lib/stripe";
import Header from "@/components/Header";
import { Button } from "@/components/ui/button";
import { ArrowLeft, Loader2 } from "lucide-react";
const PRICE_PER_KG_EXKL = 325;
const VAT_RATE = 0.25;
const PRICE_PER_KG_INK = PRICE_PER_KG_EXKL * (1 + VAT_RATE);
const WEEKS_PER_MONTH = 4;
const ChangeSubscription = () => {
const navigate = useNavigate();
const { user, loading: authLoading } = useAuth();
const { toast } = useToast();
const [currentKg, setCurrentKg] = useState<number | null>(null);
const [newKg, setNewKg] = useState<number>(10);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
useEffect(() => {
if (!authLoading && !user) navigate("/");
}, [authLoading, user, navigate]);
useEffect(() => {
(async () => {
if (!user) return;
const env = hasPaymentsConfigured() ? getStripeEnvironment() : "sandbox";
const { data } = await supabase
.from("subscriptions")
.select("kg_per_week, status")
.eq("user_id", user.id)
.eq("environment", env)
.in("status", ["active", "trialing", "past_due"])
.order("created_at", { ascending: false })
.limit(1)
.maybeSingle();
if (!data) {
toast({
title: "Inget aktivt abonnemang",
description: "Du behöver ett aktivt abonnemang för att ändra mängd.",
variant: "destructive",
});
navigate("/account");
return;
}
const kg = Number(data.kg_per_week ?? 10);
setCurrentKg(kg);
setNewKg(kg);
setLoading(false);
})();
}, [user, navigate, toast]);
const newMonthlyInk = useMemo(
() => newKg * WEEKS_PER_MONTH * PRICE_PER_KG_INK,
[newKg],
);
const currentMonthlyInk = useMemo(
() => (currentKg ?? 0) * WEEKS_PER_MONTH * PRICE_PER_KG_INK,
[currentKg],
);
const diff = newMonthlyInk - currentMonthlyInk;
const unchanged = currentKg !== null && newKg === currentKg;
const handleSave = async () => {
if (!hasPaymentsConfigured()) {
toast({ title: "Betalningar inte konfigurerade", variant: "destructive" });
return;
}
setSaving(true);
try {
const { data, error } = await supabase.functions.invoke("update-subscription-quantity", {
body: { new_kg_per_week: newKg, environment: getStripeEnvironment() },
});
if (error) throw error;
if ((data as any)?.error) throw new Error((data as any).error);
toast({
title: "Abonnemang uppdaterat",
description: `Ny mängd: ${newKg} kg/vecka. Prisdifferens justeras på nästa faktura.`,
});
navigate("/account");
} catch (e: any) {
toast({
title: "Kunde inte uppdatera",
description: e?.message ?? String(e),
variant: "destructive",
});
} finally {
setSaving(false);
}
};
if (authLoading || loading) {
return (
<div className="min-h-screen bg-background">
<Header />
<div className="pt-24 flex items-center justify-center">
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
</div>
</div>
);
}
return (
<div className="min-h-screen bg-background">
<Header />
<div className="pt-24 px-4 pb-16">
<div className="max-w-2xl mx-auto">
<button
type="button"
onClick={() => navigate("/account")}
className="text-sm text-muted-foreground hover:underline mb-6 flex items-center gap-1"
>
<ArrowLeft className="w-4 h-4" /> Tillbaka till mitt konto
</button>
<div className="text-center mb-8">
<div className="w-24 h-px bg-primary/40 mx-auto mb-6" />
<h1 className="font-display text-3xl md:text-5xl font-semibold mb-2">
Ändra abonnemang
</h1>
<p className="text-muted-foreground font-display italic">
Justera antal kg kakor per vecka
</p>
</div>
<div className="bg-card border border-border rounded-sm p-6 md:p-10 shadow-[0_10px_40px_-15px_rgba(0,0,0,0.15)] space-y-8">
<div className="text-center">
<div className="text-muted-foreground text-xs uppercase tracking-widest mb-2">
Ny mängd
</div>
<div
key={newKg}
className="font-display text-6xl md:text-7xl font-semibold text-primary tabular-nums animate-fade-in"
>
{newKg} kg
</div>
<div className="text-muted-foreground text-sm mt-1">per vecka</div>
</div>
<div>
<input
type="range"
min={1}
max={100}
step={1}
value={newKg}
onChange={(e) => setNewKg(parseInt(e.target.value, 10))}
className="calculator-slider w-full"
aria-label="Kg per vecka"
/>
<div className="flex justify-between text-xs text-muted-foreground mt-3">
<span>1 kg</span>
<span>100 kg</span>
</div>
</div>
<div className="border-t border-border pt-6 space-y-3">
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">Nuvarande månadskostnad</span>
<span className="font-medium tabular-nums">
{Math.round(currentMonthlyInk).toLocaleString("sv-SE")} kr
</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">Ny månadskostnad</span>
<span className="font-medium tabular-nums text-primary">
{Math.round(newMonthlyInk).toLocaleString("sv-SE")} kr
</span>
</div>
{!unchanged && (
<div className="flex justify-between text-sm border-t border-border pt-3">
<span className="text-muted-foreground">Skillnad</span>
<span
className={`font-semibold tabular-nums ${
diff > 0 ? "text-foreground" : "text-primary"
}`}
>
{diff > 0 ? "+" : ""}
{Math.round(diff).toLocaleString("sv-SE")} kr/mån
</span>
</div>
)}
<p className="text-xs text-muted-foreground text-center pt-2">
ink moms · prisdifferens proportioneras nästa faktura
</p>
</div>
<Button
onClick={handleSave}
disabled={saving || unchanged}
className="w-full py-6 text-base"
>
{saving ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" /> Uppdaterar...
</>
) : unchanged ? (
"Ingen ändring gjord"
) : (
`Bekräfta ${newKg} kg/vecka`
)}
</Button>
</div>
</div>
</div>
</div>
);
};
export default ChangeSubscription;
-168
View File
@@ -1,168 +0,0 @@
import { useState } from "react";
import { z } from "zod";
import { useNavigate } from "react-router-dom";
import Header from "@/components/Header";
import Footer from "@/components/Footer";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Label } from "@/components/ui/label";
import { toast } from "sonner";
import { supabase } from "@/integrations/supabase/client";
import { Send, ArrowLeft } from "lucide-react";
const contactSchema = z.object({
email: z.string().trim().email("Ogiltig e-postadress").max(255, "Max 255 tecken"),
company: z.string().trim().min(1, "Företag krävs").max(100, "Max 100 tecken"),
message: z.string().trim().min(1, "Meddelande krävs").max(2000, "Max 2000 tecken"),
});
type ContactForm = z.infer<typeof contactSchema>;
const Contact = () => {
const navigate = useNavigate();
const [form, setForm] = useState<ContactForm>({
email: "",
company: "",
message: "",
});
const [isSubmitting, setIsSubmitting] = useState(false);
const [isSubmitted, setIsSubmitted] = useState(false);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
const validation = contactSchema.safeParse(form);
if (!validation.success) {
toast.error(validation.error.errors[0].message);
return;
}
setIsSubmitting(true);
try {
const { error } = await supabase.functions.invoke("send-contact-email", {
body: {
email: form.email,
company: form.company,
message: form.message,
},
});
if (error) {
throw error;
}
setIsSubmitted(true);
toast.success("Tack för ditt meddelande!");
} catch (error: any) {
console.error("Contact form error:", error);
toast.error("Något gick fel. Försök igen senare.");
} finally {
setIsSubmitting(false);
}
};
return (
<div className="min-h-screen bg-background flex flex-col">
<Header />
<div className="px-6 pt-32">
<button
onClick={() => navigate("/")}
className="inline-flex items-center gap-2 text-muted-foreground hover:text-primary transition-colors"
>
<ArrowLeft size={16} />
<span>Tillbaka till startsidan</span>
</button>
</div>
<main className="flex-1 pt-8 pb-16 px-4">
<div className="max-w-xl mx-auto">
{/* Header */}
<div className="text-center mb-10">
<div className="w-16 h-px bg-primary/40 mx-auto mb-6"></div>
<h1 className="font-display text-3xl md:text-4xl font-semibold mb-3">
Kontakta oss
</h1>
<p className="text-muted-foreground">
Har du frågor om våra abonnemang eller vill veta mer? Hör av dig!
</p>
</div>
{isSubmitted ? (
<div className="bg-card border border-border rounded-lg p-8 text-center">
<div className="w-16 h-16 bg-primary/10 rounded-full flex items-center justify-center mx-auto mb-4">
<Send className="w-8 h-8 text-primary" />
</div>
<h2 className="font-display text-2xl font-semibold mb-2">
Tack för ditt meddelande!
</h2>
<p className="text-muted-foreground mb-6">
Vi återkommer till dig snart som möjligt.
</p>
<Button onClick={() => navigate("/")} variant="outline">
Tillbaka till startsidan
</Button>
</div>
) : (
<form onSubmit={handleSubmit} className="bg-card border border-border rounded-lg p-6 md:p-8 space-y-5">
<div className="space-y-2">
<Label htmlFor="company">Företag *</Label>
<Input
id="company"
type="text"
value={form.company}
onChange={(e) => setForm({ ...form, company: e.target.value })}
placeholder="Företagsnamn"
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="email">E-post *</Label>
<Input
id="email"
type="email"
value={form.email}
onChange={(e) => setForm({ ...form, email: e.target.value })}
placeholder="din@epost.se"
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="message">Meddelande *</Label>
<Textarea
id="message"
value={form.message}
onChange={(e) => setForm({ ...form, message: e.target.value })}
placeholder="Skriv ditt meddelande här..."
rows={5}
required
/>
</div>
<Button
type="submit"
className="w-full"
disabled={isSubmitting}
>
{isSubmitting ? (
"Skickar..."
) : (
<>
<Send className="w-4 h-4 mr-2" />
Skicka meddelande
</>
)}
</Button>
</form>
)}
</div>
</main>
<Footer />
</div>
);
};
export default Contact;
-70
View File
@@ -1,70 +0,0 @@
import { Link } from "react-router-dom";
import { ArrowLeft } from "lucide-react";
import Header from "@/components/Header";
import Footer from "@/components/Footer";
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "@/components/ui/accordion";
const faqs = [
{
question: "Vilka leveransområden har ni?",
answer: "Vi levererar till alla typer av företag i Storstockholm restauranger, caféer, konditorier, hotell, kontor och andra verksamheter som vill bjuda sina gäster och anställda på kvalitetsfika."
},
{
question: "Hur ofta sker leveranserna?",
answer: "Vi levererar en gång i veckan, samma dag varje vecka, så du alltid har färska bakverk till dina gäster och anställda."
},
{
question: "Vilken tid levererar ni?",
answer: "Vi levererar under dagtid. Har du särskilda önskemål om tid eller annat kan du ange det i din beställning eller kontakta oss via mejl."
},
{
question: "Kan jag ändra mitt abonnemang?",
answer: "Ja, du kan när som helst ändra eller pausa ditt abonnemang genom att logga in på Mitt konto."
}
];
const FAQ = () => {
return (
<div className="bg-background text-foreground min-h-screen">
<Header />
<div className="px-6 pt-32">
<Link
to="/"
className="inline-flex items-center gap-2 text-muted-foreground hover:text-primary transition-colors"
>
<ArrowLeft size={16} />
<span>Tillbaka till startsidan</span>
</Link>
</div>
<main className="max-w-3xl mx-auto px-6 pt-8 pb-16">
<h1 className="text-3xl md:text-4xl font-bold text-center mb-4">
Vanliga frågor
</h1>
<p className="text-muted-foreground text-center mb-10">
Här hittar du svar de vanligaste frågorna om våra produkter och tjänster.
</p>
<Accordion type="single" collapsible className="w-full">
{faqs.map((faq, index) => (
<AccordionItem key={index} value={`item-${index}`}>
<AccordionTrigger className="text-left">
{faq.question}
</AccordionTrigger>
<AccordionContent className="text-muted-foreground">
{faq.answer}
</AccordionContent>
</AccordionItem>
))}
</Accordion>
</main>
<Footer />
</div>
);
};
export default FAQ;
-21
View File
@@ -1,21 +0,0 @@
import Header from "@/components/Header";
import HeroSection from "@/components/HeroSection";
import HeritageSection from "@/components/HeritageSection";
import PlansSection from "@/components/PlansSection";
import DeliverySection from "@/components/DeliverySection";
import Footer from "@/components/Footer";
const Index = () => {
return (
<div className="bg-background text-foreground min-h-screen">
<Header />
<HeroSection />
<HeritageSection />
<PlansSection />
<DeliverySection />
<Footer />
</div>
);
};
export default Index;
-24
View File
@@ -1,24 +0,0 @@
import { useLocation } from "react-router-dom";
import { useEffect } from "react";
const NotFound = () => {
const location = useLocation();
useEffect(() => {
console.error("404 Error: User attempted to access non-existent route:", location.pathname);
}, [location.pathname]);
return (
<div className="flex min-h-screen items-center justify-center bg-muted">
<div className="text-center">
<h1 className="mb-4 text-4xl font-bold">404</h1>
<p className="mb-4 text-xl text-muted-foreground">Oops! Page not found</p>
<a href="/" className="text-primary underline hover:text-primary/90">
Return to Home
</a>
</div>
</div>
);
};
export default NotFound;
-108
View File
@@ -1,108 +0,0 @@
import { useSearchParams, useNavigate } from "react-router-dom";
import { CheckCircle2, Truck, Mail, Calendar } from "lucide-react";
import Header from "@/components/Header";
import { Button } from "@/components/ui/button";
const OrderConfirmation = () => {
const [searchParams] = useSearchParams();
const navigate = useNavigate();
const method = searchParams.get("method") ?? "card";
const kg = searchParams.get("kg");
const email = searchParams.get("email");
const sessionId = searchParams.get("session_id");
return (
<div className="min-h-screen bg-background">
<Header />
<main className="pt-28 md:pt-32 px-6 pb-20">
<div className="max-w-2xl mx-auto">
<div className="text-center mb-10 animate-fade-in">
<div className="w-20 h-20 rounded-full bg-primary/10 flex items-center justify-center mx-auto mb-6">
<CheckCircle2 className="w-11 h-11 text-primary" strokeWidth={1.5} />
</div>
<div className="w-24 h-px bg-primary/40 mx-auto mb-6" />
<h1 className="font-display text-4xl md:text-5xl font-semibold mb-4">
Tack för din beställning!
</h1>
<p className="font-display italic text-lg md:text-xl text-muted-foreground">
Goda saker är väg.
</p>
</div>
<div className="bg-card border border-border rounded-sm shadow-[0_10px_40px_-15px_rgba(0,0,0,0.15)] p-6 md:p-10 space-y-6">
{kg && (
<div className="text-center pb-6 border-b border-border">
<div className="text-muted-foreground text-xs uppercase tracking-widest mb-2">
Ditt abonnemang
</div>
<div className="font-display text-3xl md:text-4xl font-semibold text-primary tabular-nums">
{kg} kg kakor / vecka
</div>
</div>
)}
<div className="flex items-start gap-4">
<Truck className="w-6 h-6 text-primary flex-shrink-0 mt-0.5" strokeWidth={1.5} />
<div>
<div className="font-display text-lg font-semibold mb-1">Leverans varje tisdag</div>
<p className="text-sm text-muted-foreground leading-relaxed">
Vi levererar färska kakor till er adress inom Storstockholm varje tisdag
morgon. Första leveransen kommer inom kort.
</p>
</div>
</div>
<div className="flex items-start gap-4">
<Calendar className="w-6 h-6 text-primary flex-shrink-0 mt-0.5" strokeWidth={1.5} />
<div>
<div className="font-display text-lg font-semibold mb-1">
{method === "invoice" ? "Faktura varje månad" : "Månatlig kortdragning"}
</div>
<p className="text-sm text-muted-foreground leading-relaxed">
{method === "invoice"
? "Faktura skickas till er e-post varje månad med 30 dagars betalningsvillkor."
: "Beloppet dras automatiskt från ert kort en gång i månaden. Du kan säga upp när som helst."}
</p>
</div>
</div>
<div className="flex items-start gap-4">
<Mail className="w-6 h-6 text-primary flex-shrink-0 mt-0.5" strokeWidth={1.5} />
<div>
<div className="font-display text-lg font-semibold mb-1">Orderbekräftelse</div>
<p className="text-sm text-muted-foreground leading-relaxed">
En bekräftelse{email ? <> skickas till <strong className="text-foreground">{email}</strong></> : " skickas till din e-post"} inom kort.
</p>
</div>
</div>
{sessionId && (
<div className="pt-4 border-t border-border text-center">
<p className="text-xs text-muted-foreground font-mono break-all">
Referens: {sessionId}
</p>
</div>
)}
</div>
<div className="text-center mt-10">
<Button
onClick={() => navigate("/")}
variant="outline"
className="font-display"
>
Tillbaka till startsidan
</Button>
</div>
<p className="text-center text-sm text-muted-foreground mt-8 italic font-display">
Äkta svenskt konditorhantverk
</p>
</div>
</main>
</div>
);
};
export default OrderConfirmation;
-186
View File
@@ -1,186 +0,0 @@
import { useState, useEffect } from "react";
import { useNavigate } from "react-router-dom";
import { z } from "zod";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { useAuth } from "@/hooks/useAuth";
import { useToast } from "@/hooks/use-toast";
import { supabase } from "@/integrations/supabase/client";
import Header from "@/components/Header";
const profileSchema = z.object({
phone: z.string().trim().max(20, { message: "Telefonnummer får max vara 20 tecken" }).optional(),
address: z.string().trim().max(500, { message: "Adress får max vara 500 tecken" }).optional(),
});
interface ProfileData {
phone: string;
address: string;
}
const Profile = () => {
const [profile, setProfile] = useState<ProfileData>({
phone: "",
address: "",
});
const [isLoading, setIsLoading] = useState(true);
const [isSaving, setIsSaving] = useState(false);
const { user, loading: authLoading } = useAuth();
const { toast } = useToast();
const navigate = useNavigate();
useEffect(() => {
if (!authLoading && !user) {
navigate("/");
return;
}
if (user) {
fetchProfile();
}
}, [user, authLoading, navigate]);
const fetchProfile = async () => {
try {
const { data, error } = await supabase
.from("profiles")
.select("phone, address")
.eq("user_id", user!.id)
.maybeSingle();
if (error) {
console.error("Error fetching profile:", error);
toast({
title: "Kunde inte hämta profil",
description: error.message,
variant: "destructive",
});
} else if (data) {
setProfile({
phone: data.phone || "",
address: data.address || "",
});
}
} finally {
setIsLoading(false);
}
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
const validation = profileSchema.safeParse(profile);
if (!validation.success) {
toast({
title: "Fel",
description: validation.error.errors[0].message,
variant: "destructive",
});
return;
}
setIsSaving(true);
try {
const { error } = await supabase
.from("profiles")
.update({
phone: profile.phone || null,
address: profile.address || null,
})
.eq("user_id", user!.id);
if (error) {
toast({
title: "Kunde inte spara profil",
description: error.message,
variant: "destructive",
});
} else {
toast({
title: "Profil sparad!",
description: "Dina uppgifter har uppdaterats.",
});
}
} finally {
setIsSaving(false);
}
};
if (authLoading || isLoading) {
return (
<div className="min-h-screen bg-background">
<Header />
<div className="pt-24 flex items-center justify-center">
<p className="text-muted-foreground">Laddar...</p>
</div>
</div>
);
}
return (
<div className="min-h-screen bg-background">
<Header />
<div className="pt-24 px-4 pb-12">
<div className="max-w-lg mx-auto">
<div className="bg-card border border-border rounded-lg p-8 shadow-sm">
<h1 className="font-serif text-2xl text-center mb-2">Min profil</h1>
<p className="text-muted-foreground text-center text-sm mb-6">
{user?.email}
</p>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="phone">Telefon</Label>
<Input
id="phone"
type="tel"
value={profile.phone}
onChange={(e) =>
setProfile({ ...profile, phone: e.target.value })
}
placeholder="070-123 45 67"
/>
</div>
<div className="space-y-2">
<Label htmlFor="address">Leveransadress</Label>
<Textarea
id="address"
value={profile.address}
onChange={(e) =>
setProfile({ ...profile, address: e.target.value })
}
placeholder="Gatuadress, postnummer, ort"
rows={3}
/>
</div>
<Button
type="submit"
className="w-full bg-primary text-primary-foreground hover:bg-primary/90"
disabled={isSaving}
>
{isSaving ? "Sparar..." : "Spara ändringar"}
</Button>
</form>
<div className="text-center mt-6">
<button
type="button"
onClick={() => navigate("/")}
className="text-sm text-primary hover:underline"
>
Tillbaka till startsidan
</button>
</div>
</div>
</div>
</div>
</div>
);
};
export default Profile;
-139
View File
@@ -1,139 +0,0 @@
import { useState, useEffect } from "react";
import { useNavigate } from "react-router-dom";
import { z } from "zod";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { useAuth } from "@/hooks/useAuth";
import { useToast } from "@/hooks/use-toast";
const passwordSchema = z.object({
password: z.string().min(6, { message: "Lösenordet måste vara minst 6 tecken" }).max(100),
confirmPassword: z.string(),
}).refine((data) => data.password === data.confirmPassword, {
message: "Lösenorden matchar inte",
path: ["confirmPassword"],
});
const ResetPassword = () => {
const [password, setPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
const [isSubmitting, setIsSubmitting] = useState(false);
const [isReady, setIsReady] = useState(false);
const { updatePassword, session } = useAuth();
const { toast } = useToast();
const navigate = useNavigate();
useEffect(() => {
// Wait for session to be available (user clicked email link)
if (session) {
setIsReady(true);
}
}, [session]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
const validation = passwordSchema.safeParse({ password, confirmPassword });
if (!validation.success) {
toast({
title: "Fel",
description: validation.error.errors[0].message,
variant: "destructive",
});
return;
}
setIsSubmitting(true);
try {
const { error } = await updatePassword(password);
if (error) {
toast({
title: "Kunde inte uppdatera lösenord",
description: error.message,
variant: "destructive",
});
} else {
toast({
title: "Lösenord uppdaterat!",
description: "Du kan nu logga in med ditt nya lösenord.",
});
navigate("/");
}
} finally {
setIsSubmitting(false);
}
};
if (!isReady) {
return (
<div className="min-h-screen flex items-center justify-center bg-background px-4">
<div className="max-w-md w-full text-center">
<h1 className="font-serif text-2xl mb-4">Laddar...</h1>
<p className="text-muted-foreground">
Väntar verifiering av din återställningslänk.
</p>
</div>
</div>
);
}
return (
<div className="min-h-screen flex items-center justify-center bg-background px-4">
<div className="max-w-md w-full">
<div className="bg-card border border-border rounded-lg p-8 shadow-sm">
<h1 className="font-serif text-2xl text-center mb-6">
Välj nytt lösenord
</h1>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="password">Nytt lösenord</Label>
<Input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="••••••••"
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="confirmPassword">Bekräfta lösenord</Label>
<Input
id="confirmPassword"
type="password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
placeholder="••••••••"
required
/>
</div>
<Button
type="submit"
className="w-full bg-primary text-primary-foreground hover:bg-primary/90"
disabled={isSubmitting}
>
{isSubmitting ? "Uppdaterar..." : "Uppdatera lösenord"}
</Button>
</form>
<div className="text-center mt-4">
<button
type="button"
onClick={() => navigate("/")}
className="text-sm text-primary hover:underline"
>
Tillbaka till startsidan
</button>
</div>
</div>
</div>
</div>
);
};
export default ResetPassword;
-7
View File
@@ -1,7 +0,0 @@
import { describe, it, expect } from "vitest";
describe("example", () => {
it("should pass", () => {
expect(true).toBe(true);
});
});
-15
View File
@@ -1,15 +0,0 @@
import "@testing-library/jest-dom";
Object.defineProperty(window, "matchMedia", {
writable: true,
value: (query: string) => ({
matches: false,
media: query,
onchange: null,
addListener: () => {},
removeListener: () => {},
addEventListener: () => {},
removeEventListener: () => {},
dispatchEvent: () => {},
}),
});
-1
View File
@@ -1 +0,0 @@
/// <reference types="vite/client" />
-28
View File
@@ -1,28 +0,0 @@
project_id = "teeojvlqulhghditoakc"
[functions.generate-leads]
verify_jwt = false
[functions.research-company]
verify_jwt = false
[functions.send-contact-email]
verify_jwt = false
[functions.submit-order]
verify_jwt = false
[functions.create-account]
verify_jwt = false
[functions.create-checkout]
verify_jwt = false
[functions.payments-webhook]
verify_jwt = false
[functions.create-portal-session]
verify_jwt = false
[functions.update-subscription-quantity]
verify_jwt = false
-74
View File
@@ -1,74 +0,0 @@
import { createClient } from "https://esm.sh/@supabase/supabase-js@2.45.0";
/**
* Verifies the caller is an authenticated admin.
* Returns { ok: true } on success or { ok: false, response } with a
* ready-to-return Response on failure.
*/
export async function requireAdmin(
req: Request,
corsHeaders: Record<string, string>,
): Promise<{ ok: true; userId: string } | { ok: false; response: Response }> {
const authHeader = req.headers.get("Authorization");
if (!authHeader?.startsWith("Bearer ")) {
return {
ok: false,
response: new Response(JSON.stringify({ error: "Unauthorized" }), {
status: 401,
headers: { ...corsHeaders, "Content-Type": "application/json" },
}),
};
}
const supabaseUrl = Deno.env.get("SUPABASE_URL");
const anonKey = Deno.env.get("SUPABASE_ANON_KEY");
const serviceKey = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY");
if (!supabaseUrl || !anonKey || !serviceKey) {
return {
ok: false,
response: new Response(JSON.stringify({ error: "Server misconfigured" }), {
status: 500,
headers: { ...corsHeaders, "Content-Type": "application/json" },
}),
};
}
const supabase = createClient(supabaseUrl, anonKey, {
global: { headers: { Authorization: authHeader } },
});
const token = authHeader.replace("Bearer ", "");
const { data, error } = await supabase.auth.getClaims(token);
if (error || !data?.claims?.sub) {
return {
ok: false,
response: new Response(JSON.stringify({ error: "Unauthorized" }), {
status: 401,
headers: { ...corsHeaders, "Content-Type": "application/json" },
}),
};
}
const userId = data.claims.sub as string;
// Verify admin role via service-role client (bypasses RLS deterministically)
const admin = createClient(supabaseUrl, serviceKey);
const { data: role, error: roleErr } = await admin
.from("user_roles")
.select("role")
.eq("user_id", userId)
.eq("role", "admin")
.maybeSingle();
if (roleErr || !role) {
return {
ok: false,
response: new Response(JSON.stringify({ error: "Forbidden" }), {
status: 403,
headers: { ...corsHeaders, "Content-Type": "application/json" },
}),
};
}
return { ok: true, userId };
}
-87
View File
@@ -1,87 +0,0 @@
import { encode } from "https://deno.land/std@0.168.0/encoding/hex.ts";
import Stripe from "https://esm.sh/stripe@22.0.2";
const getEnv = (key: string): string => {
const value = Deno.env.get(key);
if (!value) throw new Error(`${key} is not configured`);
return value;
};
export type StripeEnv = "sandbox" | "live";
const GATEWAY_STRIPE_BASE = "https://connector-gateway.lovable.dev/stripe";
export function getConnectionApiKey(env: StripeEnv): string {
return env === "sandbox"
? getEnv("STRIPE_SANDBOX_API_KEY")
: getEnv("STRIPE_LIVE_API_KEY");
}
export function createStripeClient(env: StripeEnv): Stripe {
const connectionApiKey = getConnectionApiKey(env);
const lovableApiKey = getEnv("LOVABLE_API_KEY");
return new Stripe(connectionApiKey, {
apiVersion: "2026-03-25.dahlia" as any,
httpClient: Stripe.createFetchHttpClient((input, init) => {
const stripeUrl = input instanceof Request ? input.url : input.toString();
const gatewayUrl = stripeUrl.replace("https://api.stripe.com", GATEWAY_STRIPE_BASE);
return fetch(gatewayUrl, {
...init,
headers: {
...Object.fromEntries(
new Headers(
init?.headers ?? (input instanceof Request ? input.headers : undefined),
).entries(),
),
"X-Connection-Api-Key": connectionApiKey,
"Lovable-API-Key": lovableApiKey,
},
});
}),
});
}
export async function verifyWebhook(
req: Request,
env: StripeEnv,
): Promise<{ type: string; data: { object: any } }> {
const signature = req.headers.get("stripe-signature");
const body = await req.text();
const secret =
env === "sandbox"
? getEnv("PAYMENTS_SANDBOX_WEBHOOK_SECRET")
: getEnv("PAYMENTS_LIVE_WEBHOOK_SECRET");
if (!signature || !body) throw new Error("Missing signature or body");
let timestamp: string | undefined;
const v1Signatures: string[] = [];
for (const part of signature.split(",")) {
const [key, value] = part.split("=", 2);
if (key === "t") timestamp = value;
if (key === "v1") v1Signatures.push(value);
}
if (!timestamp || v1Signatures.length === 0) throw new Error("Invalid signature format");
const age = Math.abs(Date.now() / 1000 - Number(timestamp));
if (age > 300) throw new Error("Webhook timestamp too old");
const key = await crypto.subtle.importKey(
"raw",
new TextEncoder().encode(secret),
{ name: "HMAC", hash: "SHA-256" },
false,
["sign"],
);
const signed = await crypto.subtle.sign(
"HMAC",
key,
new TextEncoder().encode(`${timestamp}.${body}`),
);
const expected = new TextDecoder().decode(encode(new Uint8Array(signed)));
if (!v1Signatures.includes(expected)) throw new Error("Invalid webhook signature");
return JSON.parse(body);
}
@@ -1,91 +0,0 @@
import { createClient } from "https://esm.sh/@supabase/supabase-js@2.45.0";
import { z } from "npm:zod@3.23.8";
const corsHeaders = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Headers":
"authorization, x-client-info, x-supabase-client-platform, x-supabase-api-version, apikey, content-type",
"Access-Control-Allow-Methods": "POST, OPTIONS",
};
const BodySchema = z.object({
email: z.string().trim().email().max(255),
password: z.string().min(8).max(100),
company: z.string().trim().max(200).optional().default(""),
phone: z.string().trim().max(30).optional().default(""),
});
const admin = createClient(
Deno.env.get("SUPABASE_URL")!,
Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!,
);
Deno.serve(async (req) => {
if (req.method === "OPTIONS") return new Response("ok", { headers: corsHeaders });
if (req.method !== "POST") {
return new Response(JSON.stringify({ error: "Method not allowed" }), {
status: 405,
headers: { ...corsHeaders, "Content-Type": "application/json" },
});
}
try {
const parsed = BodySchema.safeParse(await req.json());
if (!parsed.success) {
return new Response(
JSON.stringify({ error: "Ogiltiga fält", details: parsed.error.flatten().fieldErrors }),
{ status: 400, headers: { ...corsHeaders, "Content-Type": "application/json" } },
);
}
const { email, password, company, phone } = parsed.data;
// Try to create user with email_confirm=true so they can sign in immediately
const { data: created, error: createErr } = await admin.auth.admin.createUser({
email,
password,
email_confirm: true,
user_metadata: { company, phone },
});
if (createErr) {
const msg = createErr.message || "";
const alreadyExists =
msg.toLowerCase().includes("already") ||
msg.toLowerCase().includes("registered") ||
msg.toLowerCase().includes("exists");
if (alreadyExists) {
return new Response(JSON.stringify({ ok: true, existed: true }), {
status: 200,
headers: { ...corsHeaders, "Content-Type": "application/json" },
});
}
console.error("createUser error:", createErr);
return new Response(JSON.stringify({ error: msg }), {
status: 400,
headers: { ...corsHeaders, "Content-Type": "application/json" },
});
}
// Best-effort update profile
if (created?.user?.id) {
await admin
.from("profiles")
.update({
company_name: company || null,
phone: phone || null,
})
.eq("user_id", created.user.id);
}
return new Response(JSON.stringify({ ok: true, existed: false }), {
status: 200,
headers: { ...corsHeaders, "Content-Type": "application/json" },
});
} catch (err: any) {
console.error("create-account error:", err);
return new Response(JSON.stringify({ error: err?.message ?? "Något gick fel" }), {
status: 500,
headers: { ...corsHeaders, "Content-Type": "application/json" },
});
}
});
-284
View File
@@ -1,284 +0,0 @@
import { z } from "npm:zod@3.23.8";
import { createClient } from "https://esm.sh/@supabase/supabase-js@2.45.0";
import { type StripeEnv, createStripeClient } from "../_shared/stripe.ts";
const supabaseAdmin = createClient(
Deno.env.get("SUPABASE_URL")!,
Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!,
);
async function sendOrderEmail(payload: Record<string, unknown>) {
try {
const res = await fetch(
`${Deno.env.get("SUPABASE_URL")}/functions/v1/send-order-email`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")}`,
},
body: JSON.stringify(payload),
},
);
if (!res.ok) console.error("send-order-email failed:", res.status, await res.text());
} catch (e) {
console.error("send-order-email error:", e);
}
}
async function insertOrder(row: {
method: "card" | "invoice";
b: any;
cleaned: string;
monthlyAmountOre: number;
stripeSessionId?: string | null;
stripeSubscriptionId?: string | null;
stripeCustomerId: string;
}) {
const { b, method, cleaned, monthlyAmountOre } = row;
const orderNumber = `ABO-${Date.now().toString(36).toUpperCase()}`;
const totalKr = monthlyAmountOre / 100;
const { error } = await supabaseAdmin.from("orders").insert({
order_number: orderNumber,
customer_email: b.email,
customer_name: b.company,
total_amount: totalKr,
currency: "SEK",
status: method === "invoice" ? "invoiced" : "pending",
items: [
{
typ: "Kakabonnemang",
betalning: method === "card" ? "Kort (månadsvis)" : "Faktura (månadsvis)",
antal_anställda: b.employees,
kg_per_vecka: b.kg_per_week,
pris_per_månad_exkl_moms: totalKr,
valuta: "SEK",
leverans: {
adress: b.address,
postnummer: cleaned,
stad: b.city,
},
telefon: b.phone,
kommentarer: b.comments,
stripe_session_id: row.stripeSessionId ?? null,
stripe_subscription_id: row.stripeSubscriptionId ?? null,
stripe_customer_id: row.stripeCustomerId,
},
],
});
if (error) console.error("Order insert failed:", error);
await sendOrderEmail({
order_number: orderNumber,
customer_email: b.email,
customer_name: b.company,
kg_per_week: b.kg_per_week,
employees: b.employees,
monthly_amount_sek: totalKr,
method,
address: b.address ?? "",
postal_code: cleaned,
phone: b.phone ?? "",
comments: b.comments ?? "",
});
}
const corsHeaders = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Headers": "authorization, x-client-info, x-supabase-client-platform, x-supabase-api-version, apikey, content-type",
"Access-Control-Allow-Methods": "POST, OPTIONS",
};
// TEMP TEST: 1 kr/kg (sätt tillbaka till 32500 efter testet)
const PRICE_PER_KG_ORE = 100; // 1 kr in öre
const WEEKS_PER_MONTH = 4;
const BodySchema = z.object({
method: z.enum(["card", "invoice"]),
employees: z.number().int().min(1).max(500),
kg_per_week: z.number().min(1).max(200),
company: z.string().trim().min(1).max(200),
email: z.string().trim().email().max(255),
phone: z.string().trim().max(30).optional().default(""),
address: z.string().trim().min(1).max(200),
postal_code: z.string().trim().min(1).max(20),
city: z.string().trim().min(1).max(100),
comments: z.string().trim().max(1000).optional().default(""),
return_url: z.string().url(),
environment: z.enum(["sandbox", "live"]),
});
Deno.serve(async (req) => {
if (req.method === "OPTIONS") return new Response("ok", { headers: corsHeaders });
if (req.method !== "POST") {
return new Response(JSON.stringify({ error: "Method not allowed" }), {
status: 405,
headers: { ...corsHeaders, "Content-Type": "application/json" },
});
}
try {
const raw = await req.json();
const parsed = BodySchema.safeParse(raw);
if (!parsed.success) {
return new Response(
JSON.stringify({ error: "Ogiltiga fält", details: parsed.error.flatten().fieldErrors }),
{ status: 400, headers: { ...corsHeaders, "Content-Type": "application/json" } },
);
}
const b = parsed.data;
// Stockholm postal enforcement
const cleaned = b.postal_code.replace(/\s/g, "");
if (!/^\d{5}$/.test(cleaned) || parseInt(cleaned, 10) < 10000 || parseInt(cleaned, 10) > 19999) {
return new Response(
JSON.stringify({ error: "Vi levererar endast inom Storstockholm (100 00 199 99)." }),
{ status: 400, headers: { ...corsHeaders, "Content-Type": "application/json" } },
);
}
const env: StripeEnv = b.environment;
const stripe = createStripeClient(env);
// Monthly amount in öre = kg × 4 veckor × 325 kr
const monthlyAmountOre = Math.round(b.kg_per_week * WEEKS_PER_MONTH * PRICE_PER_KG_ORE);
// Find or create customer
const existing = await stripe.customers.list({ email: b.email, limit: 1 });
const customer =
existing.data[0] ??
(await stripe.customers.create({
email: b.email,
name: b.company,
phone: b.phone || undefined,
address: b.address
? {
line1: b.address,
postal_code: cleaned,
city: b.city || undefined,
country: "SE",
}
: undefined,
metadata: {
company: b.company,
employees: String(b.employees),
kg_per_week: String(b.kg_per_week),
comments: b.comments,
},
}));
if (b.method === "card") {
// Embedded Checkout with dynamic recurring price_data
const session = await stripe.checkout.sessions.create({
mode: "subscription",
ui_mode: "embedded_page" as any,
return_url: b.return_url,
customer: customer.id,
line_items: [
{
quantity: 1,
price_data: {
currency: "sek",
product_data: {
name: "Kakabonnemang",
description: "Månatligt kakabonnemang för företag inom Storstockholm.",
tax_code: "txcd_40060003",
},
recurring: { interval: "month" },
unit_amount: monthlyAmountOre,
} as any,
},
],
automatic_tax: { enabled: true },
customer_update: { address: "auto", name: "auto" },
subscription_data: {
description: `Kakabonnemang ${b.kg_per_week} kg/vecka`,
metadata: {
company: b.company,
customer_email: b.email,
kg_per_week: String(b.kg_per_week),
employees: String(b.employees),
},
},
metadata: {
company: b.company,
customer_email: b.email,
kg_per_week: String(b.kg_per_week),
},
} as any);
await insertOrder({
method: "card",
b,
cleaned,
monthlyAmountOre,
stripeSessionId: session.id,
stripeSubscriptionId: null,
stripeCustomerId: customer.id,
});
return new Response(JSON.stringify({ clientSecret: session.client_secret }), {
status: 200,
headers: { ...corsHeaders, "Content-Type": "application/json" },
});
}
// Invoice method: create subscription with send_invoice
const subscription = await stripe.subscriptions.create({
customer: customer.id,
collection_method: "send_invoice",
days_until_due: 30,
items: [
{
price_data: {
currency: "sek",
product_data: {
name: "Kakabonnemang",
description: "Månatligt kakabonnemang för företag inom Storstockholm.",
tax_code: "txcd_40060003",
},
recurring: { interval: "month" },
unit_amount: monthlyAmountOre,
tax_behavior: "exclusive",
} as any,
quantity: 1,
},
],
automatic_tax: { enabled: true },
description: `Kakabonnemang ${b.kg_per_week} kg/vecka`,
metadata: {
company: b.company,
customer_email: b.email,
kg_per_week: String(b.kg_per_week),
employees: String(b.employees),
},
} as any);
await insertOrder({
method: "invoice",
b,
cleaned,
monthlyAmountOre,
stripeSessionId: null,
stripeSubscriptionId: subscription.id,
stripeCustomerId: customer.id,
});
return new Response(
JSON.stringify({
invoice: true,
subscription_id: subscription.id,
message: "Faktura skickas till din e-post inom kort.",
}),
{ status: 200, headers: { ...corsHeaders, "Content-Type": "application/json" } },
);
} catch (err: any) {
console.error("create-checkout error:", err);
return new Response(
JSON.stringify({ error: err?.message ?? "Något gick fel vid checkout" }),
{ status: 500, headers: { ...corsHeaders, "Content-Type": "application/json" } },
);
}
});
@@ -1,85 +0,0 @@
import { createClient } from "https://esm.sh/@supabase/supabase-js@2.45.0";
import { z } from "npm:zod@3.23.8";
import { type StripeEnv, createStripeClient } from "../_shared/stripe.ts";
const corsHeaders = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Headers":
"authorization, x-client-info, x-supabase-client-platform, x-supabase-api-version, apikey, content-type",
"Access-Control-Allow-Methods": "POST, OPTIONS",
};
const supabase = createClient(
Deno.env.get("SUPABASE_URL")!,
Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!,
);
const BodySchema = z.object({
return_url: z.string().url(),
environment: z.enum(["sandbox", "live"]),
});
Deno.serve(async (req) => {
if (req.method === "OPTIONS") return new Response("ok", { headers: corsHeaders });
if (req.method !== "POST") {
return new Response(JSON.stringify({ error: "Method not allowed" }), {
status: 405,
headers: { ...corsHeaders, "Content-Type": "application/json" },
});
}
try {
const token = req.headers.get("Authorization")?.replace("Bearer ", "") ?? "";
const { data: userData, error: authErr } = await supabase.auth.getUser(token);
if (authErr || !userData?.user) {
return new Response(JSON.stringify({ error: "Ej inloggad" }), {
status: 401,
headers: { ...corsHeaders, "Content-Type": "application/json" },
});
}
const parsed = BodySchema.safeParse(await req.json());
if (!parsed.success) {
return new Response(JSON.stringify({ error: "Ogiltiga fält" }), {
status: 400,
headers: { ...corsHeaders, "Content-Type": "application/json" },
});
}
const env: StripeEnv = parsed.data.environment;
// Hämta senaste subscription för denna user i rätt env
const { data: sub } = await supabase
.from("subscriptions")
.select("stripe_customer_id")
.eq("user_id", userData.user.id)
.eq("environment", env)
.order("created_at", { ascending: false })
.limit(1)
.maybeSingle();
if (!sub?.stripe_customer_id) {
return new Response(
JSON.stringify({ error: "Inget abonnemang hittades för detta konto." }),
{ status: 404, headers: { ...corsHeaders, "Content-Type": "application/json" } },
);
}
const stripe = createStripeClient(env);
const portal = await stripe.billingPortal.sessions.create({
customer: sub.stripe_customer_id as string,
return_url: parsed.data.return_url,
});
return new Response(JSON.stringify({ url: portal.url }), {
status: 200,
headers: { ...corsHeaders, "Content-Type": "application/json" },
});
} catch (e: any) {
console.error("create-portal-session error:", e);
return new Response(JSON.stringify({ error: e?.message ?? "Kunde inte öppna kundportal" }), {
status: 500,
headers: { ...corsHeaders, "Content-Type": "application/json" },
});
}
});
-133
View File
@@ -1,133 +0,0 @@
import { serve } from "https://deno.land/std@0.168.0/http/server.ts";
import { requireAdmin } from "../_shared/admin-auth.ts";
const corsHeaders = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Headers":
"authorization, x-client-info, apikey, content-type, x-supabase-client-platform, x-supabase-client-platform-version, x-supabase-client-runtime, x-supabase-client-runtime-version",
};
const STORSTOCKHOLM = [
"Stockholm", "Solna", "Sundbyberg", "Täby", "Danderyd", "Lidingö",
"Nacka", "Sollentuna", "Järfälla", "Upplands Väsby", "Huddinge",
"Botkyrka", "Haninge", "Tyresö", "Nynäshamn", "Vallentuna", "Vaxholm",
"Österåker", "Ekerö", "Salem", "Nykvarn", "Södertälje",
];
serve(async (req) => {
if (req.method === "OPTIONS") return new Response(null, { headers: corsHeaders });
const auth = await requireAdmin(req, corsHeaders);
if (!auth.ok) return auth.response;
try {
const LOVABLE_API_KEY = Deno.env.get("LOVABLE_API_KEY");
if (!LOVABLE_API_KEY) throw new Error("LOVABLE_API_KEY is not configured");
const response = await fetch("https://ai.gateway.lovable.dev/v1/chat/completions", {
method: "POST",
headers: {
Authorization: `Bearer ${LOVABLE_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
model: "google/gemini-3-flash-preview",
messages: [
{
role: "system",
content: `Du är B2B-säljare för Lennart Svenssons Konditorivaror, ett bageri i Stockholm som levererar färska handgjorda kakor på abonnemang till företag.
Din uppgift: identifiera potentiella B2B-kunder i STORSTOCKHOLM.
ABSOLUTA REGLER:
- Endast företag inom följande kommuner räknas som Storstockholm:
${STORSTOCKHOLM.map((k) => `${k}`).join("\n")}
- Företag utanför Storstockholm får ALDRIG genereras.
- Använd endast verifierbar, realistisk information. Hitta inte på fakta.
- Fokusera på kontor 10+ anställda, hotell, restauranger, coworking, eventlokaler, större företag med personalrum/fika.
- Variera bransch och kommun inte bara innerstaden.`,
},
{
role: "user",
content: "Generera 15 potentiella B2B-kunder för kakabonnemang i Storstockholm.",
},
],
tools: [
{
type: "function",
function: {
name: "generate_leads",
description: "Returnerar en lista med potentiella B2B-kunder i Storstockholm",
parameters: {
type: "object",
properties: {
leads: {
type: "array",
items: {
type: "object",
properties: {
company_name: { type: "string" },
industry: { type: "string" },
employee_count: { type: "number" },
district: {
type: "string",
description: "Kommun i Storstockholm (t.ex. Solna, Nacka, Södertälje)",
},
reason: { type: "string" },
},
required: ["company_name", "industry", "employee_count", "district", "reason"],
additionalProperties: false,
},
},
},
required: ["leads"],
additionalProperties: false,
},
},
},
],
tool_choice: { type: "function", function: { name: "generate_leads" } },
}),
});
if (!response.ok) {
if (response.status === 429) {
return new Response(JSON.stringify({ error: "Rate limit överskriden, försök igen senare." }), {
status: 429, headers: { ...corsHeaders, "Content-Type": "application/json" },
});
}
if (response.status === 402) {
return new Response(JSON.stringify({ error: "Krediter slut, vänligen fyll på." }), {
status: 402, headers: { ...corsHeaders, "Content-Type": "application/json" },
});
}
const errorText = await response.text();
console.error("AI gateway error:", response.status, errorText);
throw new Error(`AI gateway error: ${response.status}`);
}
const data = await response.json();
const toolCall = data.choices?.[0]?.message?.tool_calls?.[0];
if (!toolCall || toolCall.function.name !== "generate_leads") {
throw new Error("Unexpected AI response format");
}
const parsed = JSON.parse(toolCall.function.arguments);
// Extra säkerhet: filtrera bort allt utanför Storstockholm
const allowed = new Set(STORSTOCKHOLM.map((s) => s.toLowerCase()));
const leads = (parsed.leads || []).filter((l: any) =>
allowed.has(String(l.district || "").toLowerCase().trim()),
);
return new Response(JSON.stringify({ leads }), {
headers: { ...corsHeaders, "Content-Type": "application/json" },
});
} catch (error) {
console.error("Error generating leads:", error);
const errorMessage = error instanceof Error ? error.message : "Unknown error";
return new Response(JSON.stringify({ error: errorMessage }), {
status: 500, headers: { ...corsHeaders, "Content-Type": "application/json" },
});
}
});
@@ -1,169 +0,0 @@
import { createClient } from "https://esm.sh/@supabase/supabase-js@2.45.0";
import { verifyWebhook, type StripeEnv } from "../_shared/stripe.ts";
const supabase = createClient(
Deno.env.get("SUPABASE_URL")!,
Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!,
);
/** Find existing auth user by email, or create one and send magic-link invite. */
async function ensureUser(email: string, companyName?: string): Promise<string | null> {
if (!email) return null;
try {
// Search by email (paginated). For low volumes this is fine.
const { data: list } = await supabase.auth.admin.listUsers({ page: 1, perPage: 200 });
const existing = list?.users?.find((u) => u.email?.toLowerCase() === email.toLowerCase());
if (existing) return existing.id;
// Create + invite so kunden får ett välkomstmejl med länk att sätta lösenord.
const siteUrl = Deno.env.get("SUPABASE_URL")?.replace(".supabase.co", ".lovable.app") ?? undefined;
const { data: invited, error: inviteErr } = await supabase.auth.admin.inviteUserByEmail(email, {
data: { company_name: companyName ?? null },
redirectTo: siteUrl ? `${siteUrl}/account` : undefined,
});
if (inviteErr) {
console.error("inviteUserByEmail failed:", inviteErr);
return null;
}
return invited?.user?.id ?? null;
} catch (e) {
console.error("ensureUser error:", e);
return null;
}
}
async function upsertSubscription(sub: any, env: StripeEnv) {
const item = sub.items?.data?.[0];
const priceId = item?.price?.lookup_key || item?.price?.id;
const periodStart = item?.current_period_start ?? sub.current_period_start;
const periodEnd = item?.current_period_end ?? sub.current_period_end;
const customerEmail: string =
sub.metadata?.customer_email ||
sub.customer_email ||
(typeof sub.customer === "object" ? sub.customer?.email : null) ||
"";
const companyName = sub.metadata?.company ?? null;
const kgPerWeek = sub.metadata?.kg_per_week ? Number(sub.metadata.kg_per_week) : null;
const unitAmount = item?.price?.unit_amount ?? 0;
const quantity = item?.quantity ?? 1;
const monthlyAmount = (unitAmount * quantity) / 100;
// Ensure a Lovable Cloud user exists for this email, unless we can already
// link via customer metadata.userId (future-proof).
let userId: string | null = sub.metadata?.userId ?? null;
if (!userId && customerEmail) {
userId = await ensureUser(customerEmail, companyName ?? undefined);
}
await supabase.from("subscriptions").upsert(
{
user_id: userId,
customer_email: customerEmail,
company_name: companyName,
stripe_subscription_id: sub.id,
stripe_customer_id: typeof sub.customer === "string" ? sub.customer : sub.customer?.id,
price_id: priceId,
kg_per_week: kgPerWeek,
monthly_amount: monthlyAmount || null,
status: sub.status,
collection_method: sub.collection_method ?? null,
current_period_start: periodStart ? new Date(periodStart * 1000).toISOString() : null,
current_period_end: periodEnd ? new Date(periodEnd * 1000).toISOString() : null,
cancel_at_period_end: !!sub.cancel_at_period_end,
environment: env,
updated_at: new Date().toISOString(),
},
{ onConflict: "stripe_subscription_id" },
);
}
async function markCanceled(sub: any, env: StripeEnv) {
const periodEnd = sub.items?.data?.[0]?.current_period_end ?? sub.current_period_end;
await supabase
.from("subscriptions")
.update({
status: "canceled",
cancel_at_period_end: !!sub.cancel_at_period_end,
current_period_end: periodEnd ? new Date(periodEnd * 1000).toISOString() : null,
updated_at: new Date().toISOString(),
})
.eq("stripe_subscription_id", sub.id)
.eq("environment", env);
}
async function handleCheckoutCompleted(session: any, env: StripeEnv) {
// For subscription checkouts, subscription events kommer separat — men
// vi ser till att koppla user_id om vi har e-post redan här.
const email = session.customer_details?.email || session.customer_email;
if (email) {
await ensureUser(email, session.metadata?.company);
}
// Markera ordern som betald genom att matcha stripe_session_id i items[0].
const paid = session.payment_status === "paid" || session.status === "complete";
if (paid && session.id) {
// Match by scanning recent pending orders for this session id inside items[0]
const { data: rows, error: selErr } = await supabase
.from("orders")
.select("id, items, status")
.eq("status", "pending")
.order("created_at", { ascending: false })
.limit(50);
if (selErr) console.error("orders select failed:", selErr);
const match = rows?.find((r: any) =>
Array.isArray(r.items) && r.items.some((it: any) => it?.stripe_session_id === session.id)
);
if (match) {
const { error: updErr } = await supabase
.from("orders")
.update({ status: "paid", updated_at: new Date().toISOString() })
.eq("id", match.id);
if (updErr) console.error("orders update failed:", updErr);
} else {
console.log("No pending order matched session", session.id);
}
}
}
Deno.serve(async (req) => {
if (req.method !== "POST") return new Response("Method not allowed", { status: 405 });
const rawEnv = new URL(req.url).searchParams.get("env");
if (rawEnv !== "sandbox" && rawEnv !== "live") {
return new Response(JSON.stringify({ received: true, ignored: "invalid env" }), {
status: 200,
headers: { "Content-Type": "application/json" },
});
}
const env: StripeEnv = rawEnv;
try {
const event = await verifyWebhook(req, env);
console.log(`[payments-webhook:${env}] ${event.type}`);
switch (event.type) {
case "customer.subscription.created":
case "customer.subscription.updated":
await upsertSubscription(event.data.object, env);
break;
case "customer.subscription.deleted":
await markCanceled(event.data.object, env);
break;
case "checkout.session.completed":
await handleCheckoutCompleted(event.data.object, env);
break;
default:
// ignore
break;
}
return new Response(JSON.stringify({ received: true }), {
status: 200,
headers: { "Content-Type": "application/json" },
});
} catch (e) {
console.error("Webhook error:", e);
return new Response("Webhook error", { status: 400 });
}
});
@@ -1,225 +0,0 @@
import { serve } from "https://deno.land/std@0.168.0/http/server.ts";
import { requireAdmin } from "../_shared/admin-auth.ts";
const corsHeaders = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Headers":
"authorization, x-client-info, apikey, content-type, x-supabase-client-platform, x-supabase-client-platform-version, x-supabase-client-runtime, x-supabase-client-runtime-version",
};
const STORSTOCKHOLM = [
"Stockholm", "Solna", "Sundbyberg", "Täby", "Danderyd", "Lidingö",
"Nacka", "Sollentuna", "Järfälla", "Upplands Väsby", "Huddinge",
"Botkyrka", "Haninge", "Tyresö", "Nynäshamn", "Vallentuna", "Vaxholm",
"Österåker", "Ekerö", "Salem", "Nykvarn", "Södertälje",
];
interface CompanyResearchRequest {
company_name: string;
district: string;
industry: string;
}
serve(async (req) => {
if (req.method === "OPTIONS") return new Response(null, { headers: corsHeaders });
const auth = await requireAdmin(req, corsHeaders);
if (!auth.ok) return auth.response;
try {
const FIRECRAWL_API_KEY = Deno.env.get("FIRECRAWL_API_KEY");
const LOVABLE_API_KEY = Deno.env.get("LOVABLE_API_KEY");
if (!FIRECRAWL_API_KEY) throw new Error("FIRECRAWL_API_KEY is not configured");
if (!LOVABLE_API_KEY) throw new Error("LOVABLE_API_KEY is not configured");
const { company_name, district, industry }: CompanyResearchRequest = await req.json();
if (!company_name) throw new Error("company_name is required");
// Storstockholm-kontroll
const allowed = new Set(STORSTOCKHOLM.map((s) => s.toLowerCase()));
if (!allowed.has(String(district || "").toLowerCase().trim())) {
return new Response(
JSON.stringify({
error: `Vi kontaktar endast företag i Storstockholm. "${district}" ligger utanför området.`,
}),
{ status: 400, headers: { ...corsHeaders, "Content-Type": "application/json" } },
);
}
console.log(`Researching company: ${company_name} (${district})`);
// Steg 1: Research via Firecrawl
const searchResponse = await fetch("https://api.firecrawl.dev/v1/search", {
method: "POST",
headers: {
"Authorization": `Bearer ${FIRECRAWL_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
query: `${company_name} ${district} Stockholm företag verksamhet`,
limit: 3,
scrapeOptions: { formats: ["markdown"] },
}),
});
let companyInfo = "";
if (searchResponse.ok) {
const searchData = await searchResponse.json();
companyInfo = searchData.data?.slice(0, 2)
.map((r: any) => r.markdown || r.description).join("\n\n") || "";
} else {
console.error("Firecrawl search error:", await searchResponse.text());
}
// Slumpad variation för att undvika mall-liknande utskick
const openingStyles = [
"börja med en konkret observation om företaget",
"börja med en fråga om deras vardag på kontoret",
"börja med ett kort konstaterande aldrig med hälsningsfras",
"börja med en referens till deras bransch eller uppdrag",
];
const ctaStyles = [
'"Skulle det här kunna passa ert kontor?"',
'"Vill ni att jag skickar lite mer information?"',
'"Har ni redan en lösning för fika på kontoret?"',
'"Kan jag höra av mig med ett förslag?"',
'"Är det intressant för er?"',
];
const opening = openingStyles[Math.floor(Math.random() * openingStyles.length)];
const cta = ctaStyles[Math.floor(Math.random() * ctaStyles.length)];
const mentionTiffanyAge = Math.random() < 0.35; // ibland, aldrig alltid
// Steg 2: Generera personligt mejl enligt masterprompt
const emailResponse = await fetch("https://ai.gateway.lovable.dev/v1/chat/completions", {
method: "POST",
headers: {
Authorization: `Bearer ${LOVABLE_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
model: "google/gemini-3-flash-preview",
messages: [
{
role: "system",
content: `Du agerar som en av världens bästa B2B-säljare, copywriter och SDR för Lennart Svenssons Konditorivaror ett bageri i Stockholm som levererar färska handgjorda kakor på veckoabonnemang till kontor i Storstockholm.
MÅL: få mottagaren att svara på mejlet eller vilja veta mer om kakabonnemanget.
MÅLGRUPP: endast företag i Storstockholm. Skriv aldrig till någon utanför området.
SPRÅK: korrekt, professionell och naturlig svenska. Skriv som en människa aldrig som en AI.
RESEARCH: använd endast information som går att verifiera i researchmaterialet nedan. Hitta ALDRIG på fakta, siffror, namn eller händelser. Om du inte har säker info håll det generellt om deras bransch utan att påstå specifika detaljer.
VARIATION (kritisk regel två företag får aldrig få samma mejl):
- Detta mejl ska ${opening}.
- Använd följande CTA (variera formuleringen lätt om det passar): ${cta}
- Variera alltid inledning, disposition, formuleringar, argument och avslut.
TON:
- Professionell, vänlig, personlig, självsäker, kort, lätt att läsa.
- Undvik säljklyschor, spamord, överdrifter, "Jag hoppas allt är bra", "Jag tänkte bara...", och AI-liknande formuleringar.
- Använd inte många utropstecken, versaler eller överdrivna löften.
PRODUKTVÄRDE (välj det som passar företaget, inte alla på en gång):
- Färska handgjorda kakor
- Leverans direkt till arbetsplatsen varje vecka
- Enkelt abonnemang, ingen administration
- Uppskattat av personal, skapar trivsel på kontoret
- Sparar tid för den som annars ordnar fikat
AVSÄNDARE: Tiffany. ${mentionTiffanyAge ? "I detta mejl får du nämna att Tiffany är 16 år och driver företaget men bara om det glider in naturligt, aldrig för att skapa medlidande." : "Nämn inte Tiffanys ålder i detta mejl."}
SIGNATUR: avsluta alltid med:
Vänliga hälsningar,
Tiffany
Lennart Svenssons Konditorivaror
Använd ALDRIG platshållare som "(ditt namn)".
LÄNGD: max ca 130 ord i brödtexten.
CTA: exakt en fråga i slutet.`,
},
{
role: "user",
content: `Skriv ett unikt kall-mejl till:
Företag: ${company_name}
Bransch: ${industry}
Kommun (Storstockholm): ${district}
Researchmaterial (endast dessa fakta är verifierade hitta inte på mer):
${companyInfo || "Ingen specifik information hittad. Håll mejlet generellt kring branschen utan att påstå specifika detaljer om företaget."}`,
},
],
tools: [
{
type: "function",
function: {
name: "generate_email",
description: "Genererar ett unikt personligt kall-mejl enligt masterprompten",
parameters: {
type: "object",
properties: {
subject: {
type: "string",
description: "Kort, mänsklig ämnesrad utan spamord eller utropstecken",
},
body: {
type: "string",
description: "Mejlets brödtext, inkl. signatur från Tiffany",
},
personalization_points: {
type: "array",
items: { type: "string" },
description: "Verifierbara punkter från researchen som mejlet bygger på",
},
},
required: ["subject", "body", "personalization_points"],
additionalProperties: false,
},
},
},
],
tool_choice: { type: "function", function: { name: "generate_email" } },
}),
});
if (!emailResponse.ok) {
if (emailResponse.status === 429) {
return new Response(JSON.stringify({ error: "Rate limit överskriden, försök igen senare." }), {
status: 429, headers: { ...corsHeaders, "Content-Type": "application/json" },
});
}
if (emailResponse.status === 402) {
return new Response(JSON.stringify({ error: "Krediter slut, vänligen fyll på." }), {
status: 402, headers: { ...corsHeaders, "Content-Type": "application/json" },
});
}
const errorText = await emailResponse.text();
console.error("AI gateway error:", emailResponse.status, errorText);
throw new Error(`AI gateway error: ${emailResponse.status}`);
}
const emailData = await emailResponse.json();
const toolCall = emailData.choices?.[0]?.message?.tool_calls?.[0];
if (!toolCall || toolCall.function.name !== "generate_email") {
throw new Error("Unexpected AI response format");
}
const emailContent = JSON.parse(toolCall.function.arguments);
return new Response(JSON.stringify({
success: true,
company_name,
research_summary: companyInfo.substring(0, 500),
email: emailContent,
}), { headers: { ...corsHeaders, "Content-Type": "application/json" } });
} catch (error) {
console.error("Error researching company:", error);
const errorMessage = error instanceof Error ? error.message : "Unknown error";
return new Response(JSON.stringify({ error: errorMessage }), {
status: 500, headers: { ...corsHeaders, "Content-Type": "application/json" },
});
}
});
@@ -1,157 +0,0 @@
import { serve } from "https://deno.land/std@0.190.0/http/server.ts";
const corsHeaders = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Headers":
"authorization, x-client-info, apikey, content-type",
};
interface ContactRequest {
name: string;
email: string;
company: string;
message: string;
}
// HTML-escape any user-supplied string before embedding in email HTML
const esc = (s: string): string =>
s
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
// In-memory rate limit per client IP (best-effort; resets on cold start).
// Key: ip, Value: array of request timestamps (ms).
const rateBuckets = new Map<string, number[]>();
const RATE_WINDOW_MS = 60 * 60 * 1000; // 1 hour
const RATE_MAX = 5; // max 5 messages/hour per IP
function getClientIp(req: Request): string {
const fwd = req.headers.get("x-forwarded-for");
if (fwd) return fwd.split(",")[0].trim();
return req.headers.get("cf-connecting-ip") || req.headers.get("x-real-ip") || "unknown";
}
function checkRateLimit(ip: string): boolean {
const now = Date.now();
const bucket = (rateBuckets.get(ip) || []).filter((t) => now - t < RATE_WINDOW_MS);
if (bucket.length >= RATE_MAX) {
rateBuckets.set(ip, bucket);
return false;
}
bucket.push(now);
rateBuckets.set(ip, bucket);
return true;
}
const handler = async (req: Request): Promise<Response> => {
if (req.method === "OPTIONS") {
return new Response(null, { headers: corsHeaders });
}
if (req.method !== "POST") {
return new Response(JSON.stringify({ error: "Method not allowed" }), {
status: 405,
headers: { "Content-Type": "application/json", ...corsHeaders },
});
}
const ip = getClientIp(req);
if (!checkRateLimit(ip)) {
return new Response(
JSON.stringify({ error: "För många förfrågningar. Försök igen senare." }),
{
status: 429,
headers: { "Content-Type": "application/json", ...corsHeaders },
},
);
}
try {
const { name, email, company, message }: ContactRequest = await req.json();
// Validate required fields
if (!name || !email || !message) {
throw new Error("Missing required fields");
}
// Validate email format
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(email)) {
throw new Error("Invalid email format");
}
// Validate field lengths
if (
name.length > 100 ||
email.length > 255 ||
(company && company.length > 100) ||
message.length > 2000
) {
throw new Error("Field length exceeded");
}
const RESEND_API_KEY = Deno.env.get("RESEND_API_KEY");
const recipientEmail = Deno.env.get("CONTACT_EMAIL") || "kontakt@example.com";
if (!RESEND_API_KEY) {
throw new Error("RESEND_API_KEY not configured");
}
// Escape all user-supplied values before embedding in HTML
const safeName = esc(name);
const safeEmail = esc(email);
const safeCompany = company ? esc(company) : "";
const safeMessage = esc(message).replace(/\n/g, "<br />");
const res = await fetch("https://api.resend.com/emails", {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${RESEND_API_KEY}`,
},
body: JSON.stringify({
from: "Lennart Svensson Konditorivaror <noreply@lennartsvensson.se>",
to: [recipientEmail],
reply_to: email,
subject: `Kontaktförfrågan från ${safeName}`,
html: `
<h2>Ny kontaktförfrågan</h2>
<p><strong>Namn:</strong> ${safeName}</p>
<p><strong>E-post:</strong> ${safeEmail}</p>
${safeCompany ? `<p><strong>Företag:</strong> ${safeCompany}</p>` : ""}
<hr />
<p><strong>Meddelande:</strong></p>
<p>${safeMessage}</p>
`,
}),
});
if (!res.ok) {
const error = await res.text();
console.error("Resend API error:", error);
throw new Error("Failed to send email");
}
const data = await res.json();
console.log("Contact email sent successfully:", data);
return new Response(JSON.stringify({ success: true }), {
status: 200,
headers: { "Content-Type": "application/json", ...corsHeaders },
});
} catch (error: any) {
console.error("Error in send-contact-email function:", error);
return new Response(
JSON.stringify({ error: "Kunde inte skicka meddelandet." }),
{
status: 500,
headers: { "Content-Type": "application/json", ...corsHeaders },
},
);
}
};
serve(handler);
@@ -1,134 +0,0 @@
import { z } from "npm:zod@3.23.8";
const corsHeaders = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Headers":
"authorization, x-client-info, apikey, content-type",
"Access-Control-Allow-Methods": "POST, OPTIONS",
};
const GATEWAY_URL = "https://connector-gateway.lovable.dev/brevo";
const BodySchema = z.object({
order_number: z.string().min(1).max(100),
customer_email: z.string().email(),
customer_name: z.string().min(1).max(200),
kg_per_week: z.number().min(0),
employees: z.number().int().min(0),
monthly_amount_sek: z.number().min(0),
method: z.enum(["card", "invoice"]),
address: z.string().max(200).optional().default(""),
postal_code: z.string().max(20).optional().default(""),
phone: z.string().max(30).optional().default(""),
comments: z.string().max(1000).optional().default(""),
});
function fmtKr(n: number) {
return new Intl.NumberFormat("sv-SE", {
style: "currency",
currency: "SEK",
maximumFractionDigits: 0,
}).format(n);
}
Deno.serve(async (req) => {
if (req.method === "OPTIONS") return new Response("ok", { headers: corsHeaders });
try {
const LOVABLE_API_KEY = Deno.env.get("LOVABLE_API_KEY");
const BREVO_API_KEY = Deno.env.get("BREVO_API_KEY");
if (!LOVABLE_API_KEY || !BREVO_API_KEY) {
throw new Error("Email service not configured");
}
const raw = await req.json();
const parsed = BodySchema.safeParse(raw);
if (!parsed.success) {
return new Response(
JSON.stringify({ error: "Ogiltiga fält", details: parsed.error.flatten().fieldErrors }),
{ status: 400, headers: { ...corsHeaders, "Content-Type": "application/json" } },
);
}
const o = parsed.data;
const senderEmail = Deno.env.get("BREVO_SENDER_EMAIL") ?? "noreply@konditorivaror.se";
const senderName = Deno.env.get("BREVO_SENDER_NAME") ?? "Lennart Svensson Konditorivaror";
const adminEmail = Deno.env.get("ORDER_NOTIFICATION_EMAIL") ?? senderEmail;
const methodLabel = o.method === "card" ? "Kort (månadsvis)" : "Faktura (månadsvis)";
const customerHtml = `
<div style="font-family: Georgia, serif; color:#3d2817; max-width:600px; margin:0 auto; padding:24px;">
<h1 style="color:#7a4a1f;">Tack för din beställning!</h1>
<p>Hej ${o.customer_name},</p>
<p>Vi har mottagit din beställning av <strong>Kakabonnemang</strong>. Goda saker är på väg leverans varje tisdag inom Storstockholm.</p>
<h2 style="color:#7a4a1f; margin-top:24px;">Orderdetaljer</h2>
<table style="width:100%; border-collapse:collapse;">
<tr><td style="padding:6px 0;"><strong>Ordernummer:</strong></td><td>${o.order_number}</td></tr>
<tr><td style="padding:6px 0;"><strong>Mängd:</strong></td><td>${o.kg_per_week} kg/vecka</td></tr>
<tr><td style="padding:6px 0;"><strong>Antal anställda:</strong></td><td>${o.employees}</td></tr>
<tr><td style="padding:6px 0;"><strong>Månadskostnad (exkl. moms):</strong></td><td>${fmtKr(o.monthly_amount_sek)}</td></tr>
<tr><td style="padding:6px 0;"><strong>Betalning:</strong></td><td>${methodLabel}</td></tr>
</table>
<p style="margin-top:24px;">Har du frågor? Svara bara på detta mejl.</p>
<p style="color:#7a4a1f; font-style:italic;">Äkta svenskt konditorhantverk</p>
</div>`;
const adminHtml = `
<div style="font-family: Arial, sans-serif; color:#111; max-width:600px;">
<h2>Ny order: ${o.order_number}</h2>
<p><strong>Företag:</strong> ${o.customer_name}<br/>
<strong>E-post:</strong> ${o.customer_email}<br/>
<strong>Telefon:</strong> ${o.phone || "-"}</p>
<p><strong>Mängd:</strong> ${o.kg_per_week} kg/vecka<br/>
<strong>Anställda:</strong> ${o.employees}<br/>
<strong>Månadskostnad (exkl moms):</strong> ${fmtKr(o.monthly_amount_sek)}<br/>
<strong>Betalning:</strong> ${methodLabel}</p>
<p><strong>Leverans:</strong> ${o.address || "-"}, ${o.postal_code || "-"}</p>
${o.comments ? `<p><strong>Kommentar:</strong> ${o.comments}</p>` : ""}
</div>`;
async function send(to: string, subject: string, html: string) {
const res = await fetch(`${GATEWAY_URL}/v3/smtp/email`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${LOVABLE_API_KEY}`,
"X-Connection-Api-Key": BREVO_API_KEY,
},
body: JSON.stringify({
sender: { name: senderName, email: senderEmail },
to: [{ email: to }],
subject,
htmlContent: html,
}),
});
if (!res.ok) {
const body = await res.text();
console.error(`Brevo send failed [${res.status}]:`, body);
throw new Error(`Brevo ${res.status}: ${body}`);
}
return res.json();
}
const results = await Promise.allSettled([
send(o.customer_email, `Orderbekräftelse ${o.order_number} Lennart Svensson Konditorivaror`, customerHtml),
send(adminEmail, `Ny order: ${o.order_number} (${o.customer_name})`, adminHtml),
]);
const errors = results
.map((r, i) => (r.status === "rejected" ? { i, reason: String(r.reason) } : null))
.filter(Boolean);
return new Response(
JSON.stringify({ success: errors.length === 0, errors }),
{ status: 200, headers: { ...corsHeaders, "Content-Type": "application/json" } },
);
} catch (err: any) {
console.error("send-order-email error:", err);
return new Response(
JSON.stringify({ error: err?.message ?? "Kunde inte skicka mejl" }),
{ status: 500, headers: { ...corsHeaders, "Content-Type": "application/json" } },
);
}
});
@@ -1,87 +0,0 @@
import { z } from "npm:zod@3.23.8";
const corsHeaders = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Headers":
"authorization, x-client-info, apikey, content-type",
"Access-Control-Allow-Methods": "POST, OPTIONS",
};
const GATEWAY_URL = "https://connector-gateway.lovable.dev/brevo";
const BodySchema = z.object({
to_email: z.string().email(),
to_name: z.string().min(1).max(200),
subject: z.string().min(1).max(300),
body: z.string().min(1).max(10000),
});
Deno.serve(async (req) => {
if (req.method === "OPTIONS") return new Response("ok", { headers: corsHeaders });
try {
const LOVABLE_API_KEY = Deno.env.get("LOVABLE_API_KEY");
const BREVO_API_KEY = Deno.env.get("BREVO_API_KEY");
if (!LOVABLE_API_KEY || !BREVO_API_KEY) {
throw new Error("Email service not configured");
}
const parsed = BodySchema.safeParse(await req.json());
if (!parsed.success) {
return new Response(
JSON.stringify({ error: "Ogiltiga fält", details: parsed.error.flatten().fieldErrors }),
{ status: 400, headers: { ...corsHeaders, "Content-Type": "application/json" } },
);
}
const { to_email, to_name, subject, body } = parsed.data;
const senderEmail = Deno.env.get("BREVO_SENDER_EMAIL") ?? "konditorivaror@gmail.com";
const senderName = "Tiffany Lennart Svenssons Konditorivaror";
// Konvertera radbrytningar till <br> för HTML
const htmlBody = `
<div style="font-family: Georgia, serif; font-size: 15px; color:#3d2817; line-height:1.6; max-width:600px;">
${body
.split(/\n{2,}/)
.map((p) => `<p>${p.replace(/\n/g, "<br/>")}</p>`)
.join("")}
</div>`;
const res = await fetch(`${GATEWAY_URL}/v3/smtp/email`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${LOVABLE_API_KEY}`,
"X-Connection-Api-Key": BREVO_API_KEY,
},
body: JSON.stringify({
sender: { name: senderName, email: senderEmail },
to: [{ email: to_email, name: to_name }],
replyTo: { email: senderEmail, name: senderName },
subject,
htmlContent: htmlBody,
textContent: body,
}),
});
if (!res.ok) {
const errBody = await res.text();
console.error(`Brevo send failed [${res.status}]:`, errBody);
return new Response(
JSON.stringify({ error: "Brevo avvisade mejlet", status: res.status, details: errBody }),
{ status: res.status, headers: { ...corsHeaders, "Content-Type": "application/json" } },
);
}
const data = await res.json();
return new Response(JSON.stringify({ success: true, messageId: data.messageId }), {
headers: { ...corsHeaders, "Content-Type": "application/json" },
});
} catch (err: any) {
console.error("send-outreach-email error:", err);
return new Response(
JSON.stringify({ error: err?.message ?? "Kunde inte skicka mejl" }),
{ status: 500, headers: { ...corsHeaders, "Content-Type": "application/json" } },
);
}
});
-101
View File
@@ -1,101 +0,0 @@
import { createClient } from "https://esm.sh/@supabase/supabase-js@2.45.0";
import { corsHeaders } from "npm:@supabase/supabase-js@2/cors";
import { z } from "https://esm.sh/zod@3.23.8";
const BodySchema = z.object({
företag: z.string().trim().min(1).max(100),
epost: z.string().trim().email().max(255),
telefon: z.string().trim().max(30).optional().default(""),
adress: z.string().trim().max(200).optional().default(""),
postnummer: z
.string()
.trim()
.regex(/^\s*\d{3}\s*\d{2}\s*$/, "Ogiltigt postnummer")
.refine((v) => {
const n = parseInt(v.replace(/\s/g, ""), 10);
return n >= 10000 && n <= 19999;
}, "Vi levererar endast inom Storstockholm (100 00 199 99)"),
stad: z.string().trim().max(100).optional().default(""),
kommentarer: z.string().trim().max(1000).optional().default(""),
employees: z.number().int().min(1).max(1000),
kg_per_vecka: z.number().min(1).max(500),
pris_per_vecka: z.number().min(0).max(1000000),
});
Deno.serve(async (req) => {
if (req.method === "OPTIONS") {
return new Response("ok", { headers: corsHeaders });
}
try {
const parsed = BodySchema.safeParse(await req.json());
if (!parsed.success) {
return new Response(
JSON.stringify({ error: parsed.error.flatten().fieldErrors }),
{
status: 400,
headers: { ...corsHeaders, "Content-Type": "application/json" },
}
);
}
const d = parsed.data;
const supabase = createClient(
Deno.env.get("SUPABASE_URL")!,
Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!
);
const orderNumber = `ABO-${Date.now().toString(36).toUpperCase()}`;
const { data, error } = await supabase
.from("orders")
.insert({
order_number: orderNumber,
customer_email: d.epost,
customer_name: d.företag,
items: [
{
typ: "Kakabonnemang",
antal_anställda: d.employees,
kg_per_vecka: d.kg_per_vecka,
pris_per_vecka_exkl_moms: d.pris_per_vecka,
valuta: "SEK",
leverans: {
adress: d.adress,
postnummer: d.postnummer,
stad: d.stad,
},
telefon: d.telefon,
kommentarer: d.kommentarer,
},
],
total_amount: d.pris_per_vecka,
currency: "SEK",
status: "pending",
})
.select("id, order_number")
.single();
if (error) {
console.error("Order insert failed:", error);
return new Response(
JSON.stringify({ error: "Kunde inte spara beställning", details: error.message }),
{
status: 500,
headers: { ...corsHeaders, "Content-Type": "application/json" },
}
);
}
return new Response(JSON.stringify({ ok: true, order: data }), {
status: 200,
headers: { ...corsHeaders, "Content-Type": "application/json" },
});
} catch (e) {
console.error("submit-order error:", e);
return new Response(JSON.stringify({ error: (e as Error).message }), {
status: 500,
headers: { ...corsHeaders, "Content-Type": "application/json" },
});
}
});
@@ -1,132 +0,0 @@
import { createClient } from "https://esm.sh/@supabase/supabase-js@2.45.0";
import { z } from "npm:zod@3.23.8";
import { type StripeEnv, createStripeClient } from "../_shared/stripe.ts";
const corsHeaders = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Headers":
"authorization, x-client-info, x-supabase-client-platform, x-supabase-api-version, apikey, content-type",
"Access-Control-Allow-Methods": "POST, OPTIONS",
};
const PRICE_PER_KG_ORE = 32500; // 325 kr exkl. moms
const WEEKS_PER_MONTH = 4;
const supabase = createClient(
Deno.env.get("SUPABASE_URL")!,
Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!,
);
const BodySchema = z.object({
new_kg_per_week: z.number().min(1).max(200),
environment: z.enum(["sandbox", "live"]),
});
Deno.serve(async (req) => {
if (req.method === "OPTIONS") return new Response("ok", { headers: corsHeaders });
if (req.method !== "POST") {
return new Response(JSON.stringify({ error: "Method not allowed" }), {
status: 405,
headers: { ...corsHeaders, "Content-Type": "application/json" },
});
}
try {
const token = req.headers.get("Authorization")?.replace("Bearer ", "") ?? "";
const { data: userData, error: authErr } = await supabase.auth.getUser(token);
if (authErr || !userData?.user) {
return new Response(JSON.stringify({ error: "Ej inloggad" }), {
status: 401,
headers: { ...corsHeaders, "Content-Type": "application/json" },
});
}
const parsed = BodySchema.safeParse(await req.json());
if (!parsed.success) {
return new Response(JSON.stringify({ error: "Ogiltiga fält" }), {
status: 400,
headers: { ...corsHeaders, "Content-Type": "application/json" },
});
}
const env: StripeEnv = parsed.data.environment;
const newKg = parsed.data.new_kg_per_week;
const { data: sub } = await supabase
.from("subscriptions")
.select("id, stripe_subscription_id, kg_per_week")
.eq("user_id", userData.user.id)
.eq("environment", env)
.in("status", ["active", "trialing", "past_due"])
.order("created_at", { ascending: false })
.limit(1)
.maybeSingle();
if (!sub?.stripe_subscription_id) {
return new Response(
JSON.stringify({ error: "Inget aktivt abonnemang hittades." }),
{ status: 404, headers: { ...corsHeaders, "Content-Type": "application/json" } },
);
}
const stripe = createStripeClient(env);
const stripeSub = await stripe.subscriptions.retrieve(sub.stripe_subscription_id);
const item = stripeSub.items?.data?.[0];
if (!item) {
return new Response(JSON.stringify({ error: "Kunde inte hitta prisrad" }), {
status: 500,
headers: { ...corsHeaders, "Content-Type": "application/json" },
});
}
const productId =
typeof item.price.product === "string" ? item.price.product : item.price.product.id;
const newUnitAmount = Math.round(newKg * WEEKS_PER_MONTH * PRICE_PER_KG_ORE);
await stripe.subscriptions.update(sub.stripe_subscription_id, {
items: [
{
id: item.id,
price_data: {
currency: "sek",
product: productId,
recurring: { interval: "month" },
unit_amount: newUnitAmount,
} as any,
quantity: 1,
},
],
proration_behavior: "create_prorations",
metadata: {
...(stripeSub.metadata ?? {}),
kg_per_week: String(newKg),
},
description: `Kakabonnemang ${newKg} kg/vecka`,
} as any);
// Uppdatera vår tabell direkt (webhook uppdaterar också, detta ger snabb UI-återkoppling)
await supabase
.from("subscriptions")
.update({
kg_per_week: newKg,
monthly_amount: newUnitAmount / 100,
updated_at: new Date().toISOString(),
})
.eq("id", sub.id);
return new Response(
JSON.stringify({
success: true,
new_kg_per_week: newKg,
new_monthly_amount_exkl_moms: newUnitAmount / 100,
}),
{ status: 200, headers: { ...corsHeaders, "Content-Type": "application/json" } },
);
} catch (e: any) {
console.error("update-subscription-quantity error:", e);
return new Response(JSON.stringify({ error: e?.message ?? "Kunde inte uppdatera abonnemang" }), {
status: 500,
headers: { ...corsHeaders, "Content-Type": "application/json" },
});
}
});
@@ -1,62 +0,0 @@
-- Create profiles table for user data
CREATE TABLE public.profiles (
id UUID NOT NULL DEFAULT gen_random_uuid() PRIMARY KEY,
user_id UUID NOT NULL UNIQUE REFERENCES auth.users(id) ON DELETE CASCADE,
display_name TEXT,
phone TEXT,
address TEXT,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now()
);
-- Enable Row Level Security
ALTER TABLE public.profiles ENABLE ROW LEVEL SECURITY;
-- Users can view their own profile
CREATE POLICY "Users can view their own profile"
ON public.profiles
FOR SELECT
USING (auth.uid() = user_id);
-- Users can insert their own profile
CREATE POLICY "Users can insert their own profile"
ON public.profiles
FOR INSERT
WITH CHECK (auth.uid() = user_id);
-- Users can update their own profile
CREATE POLICY "Users can update their own profile"
ON public.profiles
FOR UPDATE
USING (auth.uid() = user_id);
-- Create function to update timestamps
CREATE OR REPLACE FUNCTION public.update_updated_at_column()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = now();
RETURN NEW;
END;
$$ LANGUAGE plpgsql SET search_path = public;
-- Create trigger for automatic timestamp updates
CREATE TRIGGER update_profiles_updated_at
BEFORE UPDATE ON public.profiles
FOR EACH ROW
EXECUTE FUNCTION public.update_updated_at_column();
-- Create function to automatically create profile on signup
CREATE OR REPLACE FUNCTION public.handle_new_user()
RETURNS TRIGGER AS $$
BEGIN
INSERT INTO public.profiles (user_id)
VALUES (NEW.id);
RETURN NEW;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER SET search_path = public;
-- Create trigger to auto-create profile on user signup
CREATE TRIGGER on_auth_user_created
AFTER INSERT ON auth.users
FOR EACH ROW
EXECUTE FUNCTION public.handle_new_user();
@@ -1,3 +0,0 @@
-- Add company_name column to profiles table
ALTER TABLE public.profiles
ADD COLUMN company_name text;
@@ -1,5 +0,0 @@
-- Add separate address fields to profiles table
ALTER TABLE public.profiles
ADD COLUMN street_address text,
ADD COLUMN postal_code text,
ADD COLUMN city text;
@@ -1,36 +0,0 @@
-- Create orders table to store e-commerce orders
CREATE TABLE public.orders (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
order_number TEXT NOT NULL,
customer_email TEXT NOT NULL,
customer_name TEXT,
items JSONB NOT NULL DEFAULT '[]',
total_amount DECIMAL(10,2) NOT NULL,
currency TEXT NOT NULL DEFAULT 'SEK',
status TEXT NOT NULL DEFAULT 'pending',
shopify_checkout_id TEXT,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now()
);
-- Enable RLS
ALTER TABLE public.orders ENABLE ROW LEVEL SECURITY;
-- Create policy for authenticated users (admins) to view all orders
CREATE POLICY "Authenticated users can view all orders"
ON public.orders
FOR SELECT
TO authenticated
USING (true);
-- Create policy for inserting orders (from edge function)
CREATE POLICY "Service role can insert orders"
ON public.orders
FOR INSERT
WITH CHECK (true);
-- Create trigger for updated_at
CREATE TRIGGER update_orders_updated_at
BEFORE UPDATE ON public.orders
FOR EACH ROW
EXECUTE FUNCTION public.update_updated_at_column();
@@ -1,50 +0,0 @@
-- Create customers table
CREATE TABLE public.customers (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
email TEXT NOT NULL UNIQUE,
name TEXT,
phone TEXT,
company TEXT,
total_orders INTEGER DEFAULT 0,
total_spent DECIMAL(10,2) DEFAULT 0,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now()
);
-- Create payments table
CREATE TABLE public.payments (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
order_id UUID REFERENCES public.orders(id) ON DELETE CASCADE,
amount DECIMAL(10,2) NOT NULL,
currency TEXT NOT NULL DEFAULT 'SEK',
status TEXT NOT NULL DEFAULT 'pending',
payment_method TEXT,
shopify_payment_id TEXT,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now()
);
-- Add customer reference to orders
ALTER TABLE public.orders ADD COLUMN customer_id UUID REFERENCES public.customers(id);
-- Enable RLS
ALTER TABLE public.customers ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.payments ENABLE ROW LEVEL SECURITY;
-- RLS policies for customers (only authenticated admins)
CREATE POLICY "Authenticated users can view customers"
ON public.customers FOR SELECT TO authenticated USING (true);
CREATE POLICY "Authenticated users can manage customers"
ON public.customers FOR ALL TO authenticated USING (true) WITH CHECK (true);
-- RLS policies for payments
CREATE POLICY "Authenticated users can view payments"
ON public.payments FOR SELECT TO authenticated USING (true);
CREATE POLICY "Service can insert payments"
ON public.payments FOR INSERT WITH CHECK (true);
-- Triggers for updated_at
CREATE TRIGGER update_customers_updated_at
BEFORE UPDATE ON public.customers
FOR EACH ROW EXECUTE FUNCTION public.update_updated_at_column();
@@ -1,62 +0,0 @@
-- Role enum
DO $$ BEGIN
CREATE TYPE public.app_role AS ENUM ('admin', 'user');
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
-- user_roles table
CREATE TABLE IF NOT EXISTS public.user_roles (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
user_id uuid NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
role public.app_role NOT NULL,
created_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (user_id, role)
);
GRANT SELECT ON public.user_roles TO authenticated;
GRANT ALL ON public.user_roles TO service_role;
ALTER TABLE public.user_roles ENABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS "Users can view own roles" ON public.user_roles;
CREATE POLICY "Users can view own roles"
ON public.user_roles FOR SELECT
TO authenticated
USING (auth.uid() = user_id);
-- Security definer role check
CREATE OR REPLACE FUNCTION public.has_role(_user_id uuid, _role public.app_role)
RETURNS boolean
LANGUAGE sql STABLE SECURITY DEFINER SET search_path = public
AS $$
SELECT EXISTS (
SELECT 1 FROM public.user_roles
WHERE user_id = _user_id AND role = _role
)
$$;
-- Backfill admin for existing owner
INSERT INTO public.user_roles (user_id, role)
SELECT id, 'admin'::public.app_role
FROM auth.users
WHERE lower(email) = 'tiffanysvson@gmail.com'
ON CONFLICT (user_id, role) DO NOTHING;
-- Auto-grant admin on future signup with owner email
CREATE OR REPLACE FUNCTION public.handle_new_user()
RETURNS trigger
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
AS $$
BEGIN
INSERT INTO public.profiles (user_id) VALUES (NEW.id)
ON CONFLICT DO NOTHING;
IF lower(NEW.email) = 'tiffanysvson@gmail.com' THEN
INSERT INTO public.user_roles (user_id, role)
VALUES (NEW.id, 'admin')
ON CONFLICT (user_id, role) DO NOTHING;
END IF;
RETURN NEW;
END;
$$;
@@ -1,7 +0,0 @@
REVOKE ALL ON FUNCTION public.has_role(uuid, public.app_role) FROM PUBLIC, anon;
GRANT EXECUTE ON FUNCTION public.has_role(uuid, public.app_role) TO authenticated, service_role;
REVOKE ALL ON FUNCTION public.handle_new_user() FROM PUBLIC, anon, authenticated;
REVOKE ALL ON FUNCTION public.update_updated_at_column() FROM PUBLIC, anon, authenticated;
@@ -1,27 +0,0 @@
-- Orders: admin-only reads
DROP POLICY IF EXISTS "Authenticated users can view all orders" ON public.orders;
CREATE POLICY "Admins can view orders"
ON public.orders FOR SELECT
TO authenticated
USING (public.has_role(auth.uid(), 'admin'));
-- Customers: admin-only
DROP POLICY IF EXISTS "Authenticated users can manage customers" ON public.customers;
DROP POLICY IF EXISTS "Authenticated users can view customers" ON public.customers;
CREATE POLICY "Admins can view customers"
ON public.customers FOR SELECT
TO authenticated
USING (public.has_role(auth.uid(), 'admin'));
CREATE POLICY "Admins can manage customers"
ON public.customers FOR ALL
TO authenticated
USING (public.has_role(auth.uid(), 'admin'))
WITH CHECK (public.has_role(auth.uid(), 'admin'));
-- Payments: admin-only reads
DROP POLICY IF EXISTS "Authenticated users can view payments" ON public.payments;
CREATE POLICY "Admins can view payments"
ON public.payments FOR SELECT
TO authenticated
USING (public.has_role(auth.uid(), 'admin'));
@@ -1,3 +0,0 @@
DROP POLICY IF EXISTS "Service role can insert orders" ON public.orders;
DROP POLICY IF EXISTS "Service can insert payments" ON public.payments;

Some files were not shown because too many files have changed in this diff Show More